2026-10-19 10:23:38 +08:00
< script setup lang = "ts" >
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-10-19 10:23:38 +08:00
import { computed , onBeforeUnmount , ref , watch } from 'vue'
import { ElMessage , ElMessageBox } from 'element-plus'
import { onBeforeRouteLeave } from 'vue-router'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError , getApiErrorDetails } from '@/shared/api/http'
import { getTrip } from '../api/trips'
import { getPickupTasks , commandPickupTrip , commandPickupTask , uploadPickupEvidence , downloadPickupEvidence , pickupStatusLabels ,
type PickupTask , type PickupCommand , type PickupConfirmation , type PickupEvidence } from '../api/pickups'
import { overseasMasterDataPermissions as permission } from '../permissions'
import type { TripDetail } from '../tripTypes'
const props = defineProps < { detail : TripDetail } > (), emit = defineEmits < { saved : [ detail : TripDetail ]; locked : [ value : boolean ] } > ()
const access = useAccessPreviewStore (), rows = ref < PickupTask [] >([]), loading = ref ( false ), busy = ref ( false ), error = ref ( '' )
const selected = ref < PickupTask | null >( null ), open = ref ( false ), actual = ref ( 1 ), remark = ref ( '' ), evidence = ref < PickupEvidence [] >([])
const needsTripRefresh = ref ( false )
const pending = ref < { action : 'depart' | 'complete' | 'picked' | 'arrive' ; row : PickupTask | null ; body : PickupCommand | PickupConfirmation } | null > ( null )
// 命令结果未知时阻止父页面刷新和改派,保留原请求用于幂等重试。
watch (() => busy . value || !! pending . value , value => emit ( 'locked' , value ), { flush : 'sync' })
let sequence = 0
const canComplete = computed (() => rows . value . length > 0 && rows . value . every ( row => row . status === 'ARRIVED' ))
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 175 , fixed : 'left' , slotName : 'actions' },
{ key : 'transferNo' , prop : 'transferNo' , label : '仓储转运单' , minWidth : 220 },
{ key : 'status' , label : '取货进度' , slotName : 'status' , width : 110 }, { key : 'pickupAddress' , prop : 'pickupAddress' , label : '取货地址' , minWidth : 270 },
{ key : 'contactName' , prop : 'contactName' , label : '联系人' , width : 110 }, { key : 'contactPhone' , prop : 'contactPhone' , label : '电话' , minWidth : 145 },
{ key : 'packageCount' , prop : 'packageCount' , label : '预报箱数' , width : 100 }, { key : 'actualPackages' , label : '实提箱数' , slotName : 'actual' , width : 100 },
{ key : 'evidence' , label : '取货凭证' , slotName : 'evidence' , minWidth : 180 }, { key : 'remark' , prop : 'remark' , label : '取货备注' , minWidth : 180 },
]
/** 车次变化时丢弃旧请求;同车版本变化保留未确定结果的命令。 */
async function load () {
const current = ++ sequence ; loading . value = true ; error . value = ''
try {
if ( needsTripRefresh . value ) {
const detail = await getTrip ( props . detail . trip . tripNo )
if ( current !== sequence ) return
emit ( 'saved' , detail ); needsTripRefresh . value = false
}
const value = await getPickupTasks ( props . detail . trip . tripNo ); if ( current === sequence ) rows . value = value
}
catch ( cause ) { if ( current === sequence ) error . value = getApiErrorDetails ( cause , '取货任务加载失败' ). message }
finally { if ( current === sequence ) loading . value = false }
}
/** 逐票录入实提,默认箱数仅供编辑,需操作员确认。 */
function edit ( row : PickupTask ) {
if ( pending . value ) return
selected . value = row ; actual . value = row . packageCount ; remark . value = '' ; evidence . value = []; open . value = true
}
/** 凭证上传与确认分开,重试确认不重新上传文件。 */
async function upload ( event : Event ) {
const input = event . target as HTMLInputElement , file = input . files ? .[ 0 ]
if ( ! file || ! selected . value || busy . value ) return
busy . value = true ; error . value = '' ; const current = sequence
try { const value = await uploadPickupEvidence ( props . detail . trip . tripNo , selected . value , file ); if ( current === sequence ) evidence . value . push ( value ) }
catch ( cause ) { if ( current === sequence ) error . value = getApiErrorDetails ( cause , '取货凭证上传失败' ). message }
finally { if ( current === sequence ) busy . value = false ; input . value = '' }
}
/** 失败结果未知时固定原请求,成功后只刷新车次版本和成员。 */
async function execute () {
if ( ! pending . value || busy . value ) return
const request = pending . value , current = sequence , number = props . detail . trip . tripNo
busy . value = true ; error . value = ''
try {
if ( request . action === 'depart' || request . action === 'complete' ) {
const detail = await commandPickupTrip ( number , request . action , request . body as PickupCommand )
if ( current === sequence ) { emit ( 'saved' , detail ); pending . value = null }
} else if ( request . row ) {
const tasks = await commandPickupTask ( number , request . row , request . action , request . body )
if ( current !== sequence ) return
// 业务命令已经成功,后续刷新失败只重试读取,不能再次生成执行命令。
rows . value = tasks ; pending . value = null ; open . value = false ; needsTripRefresh . value = true
const detail = await getTrip ( number ); if ( current === sequence ) { emit ( 'saved' , detail ); needsTripRefresh . value = false }
}
} catch ( cause ) {
if ( current === sequence ) { error . value = getApiErrorDetails ( cause , '取货操作未完成' ). message ; if ( cause instanceof ApiResponseError ) pending . value = null }
} finally { if ( current === sequence ) busy . value = false }
}
/** 发车、送仓和结案提交前说明本次事实。 */
async function command ( action : 'depart' | 'arrive' | 'complete' , row : PickupTask | null = null ) {
if ( pending . value || busy . value || needsTripRefresh . value ) return
const messages = { depart : '确认车辆和司机已出发执行本车取货任务?' , arrive : '确认该单货物已送达选定海外仓?确认后仓库可开始 PDA 清点。' , complete : '确认本车全部取货订单已送仓,结束本次取货运输?' }
2026-10-20 23:34:11 +08:00
try { await ElMessageBox . confirm ( messages [ action ], '确认取货运输' , { customClass : 'admin-density-confirm admin-workbench is-dialog' }) } catch { return }
2026-10-19 10:23:38 +08:00
pending . value = { action , row , body : { recordVersion : props . detail . trip . recordVersion , idempotencyKey : crypto . randomUUID (), reason : messages [ action ] } }
await execute ()
}
/** 照片和实提箱数为取货确认的必要信息。 */
async function confirmPickup () {
if ( pending . value ) { await execute (); return }
if ( needsTripRefresh . value ) { ElMessage . warning ( '请先刷新车次后继续' ); return }
if ( ! selected . value || ! Number . isInteger ( actual . value ) || actual . value <= 0 || ! evidence . value . length ) { ElMessage . warning ( '请填写实提箱数并上传至少一份取货凭证' ); return }
pending . value = { action : 'picked' , row : selected . value , body : { recordVersion : props . detail . trip . recordVersion , idempotencyKey : crypto . randomUUID (), actualPackages : actual . value , evidenceFileIds : evidence . value . map ( file => file . fileId ), remark : remark . value . trim () } }
await execute ()
}
/** 只下载本票确认的文件,使用完即释放临时地址。 */
async function download ( row : PickupTask , file : PickupEvidence ) {
try { const blob = await downloadPickupEvidence ( row , file . fileId ); const url = URL . createObjectURL ( blob ); const link = document . createElement ( 'a' ); link . href = url ; link . download = file . fileName ; link . click (); setTimeout (() => URL . revokeObjectURL ( url ), 1000 ) }
catch ( cause ) { error . value = getApiErrorDetails ( cause , '凭证下载失败' ). message }
}
/** 原命令结果未知时保持弹框与请求快照。 */
function close ( done : () => void ) { if ( busy . value || pending . value ) { ElMessage . warning ( '请先确认原取货操作的结果' ); return }; done () }
onBeforeRouteLeave (() => { if ( busy . value || pending . value ) { ElMessage . warning ( '请先重试并确认原取货操作' ); return false }; return true })
watch (() => props . detail . trip . tripNo , () => { pending . value = null ; open . value = false ; rows . value = []; void load () }, { immediate : true })
onBeforeUnmount (() => { sequence ++ })
</ script >
< template >
< section aria -label =" 本车仓储转运取货任务 " >
< div class = "density-actions pickup-actions" >
< el-button v-if = "detail.trip.status === 'PLANNED' && access.can(permission.tripAssign)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !detail.trip.vehicleId || !detail.trip.driverId" @click="command('depart')" > 确认取货发车 </ el -button >
< el-button v-if = "detail.trip.status === 'IN_TRANSIT' && access.can(permission.tripComplete)" type="primary" :disabled="busy || !!pending || needsTripRefresh || !canComplete" @click="command('complete')" > 取货运输结案 </ el -button >
< el-button : disabled = "busy || !!pending" @click ="load" > 刷新取货任务 </ el -button >
< el-button v-if = "pending && !open" type="primary" :loading="busy" @click="execute" > 重试原操作 </ el -button >
</ div >
< LoadErrorState v-if = "error && !open" title="取货操作未完成" :message="error" @retry="pending ? execute() : load()" />
2026-10-20 23:34:11 +08:00
< div class = "workbench-table" >< DataTable :data = "rows" :columns = "columns" :loading = "loading" row -key =" transferNo " empty -text =" 暂无取货成员 " >
2026-10-19 10:23:38 +08:00
< template # actions = "{ row }" >< div class = "density-actions" >< el-button v-if = "detail.trip.status === 'IN_TRANSIT' && row.status === 'ASSIGNED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="edit(row as PickupTask)">确认取货</el-button><el-button v-if="detail.trip.status === 'IN_TRANSIT' && row.status === 'PICKED' && access.can(permission.tripEdit)" :disabled="busy || !!pending || needsTripRefresh" @click="command('arrive', row as PickupTask)" > 确认送仓 </ el -button ></ div ></ template >
< template # status = "{ row }" >{{ pickupStatusLabels [ row . status ] || row . status }}</ template >< template # actual = "{ row }" >{{ row . actualPackages ?? '待取货' }}</ template >
< template # evidence = "{ row }" >< div v-for = "file in row.evidence" :key="file.fileId"><el-button link @click="download(row as PickupTask, file)" >{{ file.fileName }} </ el -button ></ div ></ template >
2026-10-20 23:34:11 +08:00
</ DataTable ></ div >
< AppDialog v-model = "open" class="admin-density-dialog admin-workbench is-dialog" :before-close="close" title="确认本单取货" width="680px" :confirm-loading="busy" :confirm-text="pending ? '重试原取货确认' : '确认已取货'" @confirm="confirmPickup" >
2026-10-19 10:23:38 +08:00
< p >{{ selected ? . transferNo }} · {{ selected ? . pickupAddress }}</ p >
< el-form label -position =" top " : disabled = "busy || !!pending" >
2026-10-19 12:20:13 +08:00
< el-form-item label = "实提箱数" required >< el-input-number v-model = "actual" :min="1" :max="5000" :precision="0" placeholder="请输入实提箱数" /></ el-form-item >
2026-10-19 10:23:38 +08:00
< el-form-item label = "取货凭证" required >< div >< input type = "file" accept = "image/png,image/jpeg,application/pdf" : disabled = "busy || !!pending || evidence.length >= 10" @change ="upload" >< p v-for = "file in evidence" :key="file.fileId" >{{ file.fileName }}</ p ></ div > </ el -form-item >
2026-10-19 12:20:13 +08:00
< el-form-item label = "取货备注" >< el-input v-model = "remark" type="textarea" maxlength="1000" placeholder="请输入取货备注(选填)" /></ el-form-item >
2026-10-19 10:23:38 +08:00
</ el-form >< el-alert v-if = "error" :title="error" type="error" :closable="false" />
</ AppDialog >
</ section >
</ template >
< style scoped >. pickup - actions { margin - bottom : 16 px }</ style >