2026-08-25 20:57:51 +08:00
< script setup lang = "ts" >
2026-09-14 20:03:27 +08:00
import '@/styles/adminDensity.css'
2026-09-01 14:23:53 +08:00
import { computed , onMounted , ref } from 'vue'
2026-09-15 15:24:59 +08:00
import { Plus , Search } from '@lucide/vue'
2026-08-26 15:48:34 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
2026-09-01 14:23:53 +08:00
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-25 20:57:51 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-08-26 15:48:34 +08:00
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-08-25 20:57:51 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-09-01 14:23:53 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
2026-08-26 15:48:34 +08:00
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
2026-09-10 15:33:54 +08:00
import { cancelOutbound , createOneClickDropshipOutbound , createReturnOutbound , listOneClickOutboundCandidates , listReturnOutboundCandidates , listOutbounds , type OneClickOutboundCandidate , type OutboundRow , type ReturnOutboundCandidate } from '../api/outbounds'
import { type InventoryBalanceDto , type InventoryPositionDto } from '../api/inventory'
2026-08-28 14:33:57 +08:00
import { formatOverseasDateTime } from '../lastMilePresentation'
2026-09-01 14:23:53 +08:00
import { overseasWarehousePermissions as permission } from '../permissions'
2026-09-10 20:31:17 +08:00
import { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
2026-09-10 15:33:54 +08:00
import { listPlatformWarehouses , type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { getReturnCarton , listReturnCartons , listReturnStock , type ReturnCartonDetail , type ReturnCartonRow , type ReturnStockRow } from '../api/returnInventory'
2026-08-25 20:57:51 +08:00
2026-09-01 14:23:53 +08:00
const router = useRouter (); const access = useAccessPreviewStore ()
const rows = ref < OutboundRow [] >([]); const page = ref ( 1 ); const pageSize = ref ( 20 ); const total = ref ( 0 ); const loading = ref ( false ); const error = ref < ApiErrorDetails | null >( null ); const mutationError = ref < ApiErrorDetails | null >( null )
2026-09-10 15:33:54 +08:00
const filters = ref < QueryModel >({ documentNo : '' , lxCode : '' , warehouseId : '' , status : '' , outboundType : '' }); const createOpen = ref ( false ); const saving = ref ( false ); const recipient = ref ( '' ); const remark = ref ( '' ); const quantity = ref < number | undefined >(); const cartonMarks = ref ( '' ); const selectedReturnMarks = ref < string [] >([]); const selectedCustomerId = ref ( '' ); const selectedPositionId = ref ( '' ); const positionOptions = ref < Array < { balance : InventoryBalanceDto ; position : InventoryPositionDto }> > ([]); const dropshipCandidates = ref < OneClickOutboundCandidate [] >([]); const selectedDropshipIds = ref < string [] >([]); const returnCandidates = ref < ReturnOutboundCandidate [] >([]); const selectedWarehouseId = ref ( '' ); const selectedReturnNo = ref ( '' ); const outboundType = ref < 'ONE_CLICK_DROPSHIP' | 'RETURN_OUTBOUND' > ( 'ONE_CLICK_DROPSHIP' ); const inventoryLoading = ref ( false ); const dropshipQueryInput = ref ( '' ); const dropshipQuery = ref ( '' ); const dropshipPage = ref ( 1 ); const dropshipPageSize = ref ( 5 )
const tenantOptions = ref < PlatformTenantResource [] >([])
const warehouseOptionsSource = ref < PlatformWarehouseSummaryResource [] >([])
const returnStockRows = ref < ReturnStockRow [] >([])
const returnCartonRows = ref < ReturnCartonRow [] >([])
const selectedReturnSkus = ref < string [] >([])
const returnSkuQueryInput = ref ( '' ); const returnSkuQuery = ref ( '' )
const returnCartonQueryInput = ref ( '' ); const returnCartonQuery = ref ( '' )
const returnSkuPage = ref ( 1 ); const returnCartonPage = ref ( 1 )
const returnSkuPageSize = ref ( 5 ); const returnCartonPageSize = ref ( 5 )
const returnCartonDetails = ref < Record < string , ReturnCartonDetail > > ({})
2026-09-01 14:23:53 +08:00
const customerOptions = computed (() => {
const customers = new Map < string , string >()
2026-09-10 15:33:54 +08:00
tenantOptions . value . forEach (( item ) => {
if ( item . status !== 'ACTIVE' ) return
const name = item . tenantName ? . trim () || '客户名称待补充'
const phone = item . contactPhone ? . trim ()
customers . set ( String ( item . id ), phone ? ` ${ name } - ${ phone } ` : name )
})
2026-09-01 14:23:53 +08:00
positionOptions . value . forEach (({ balance }) => customers . set ( balance . customerId , balance . customerName ? . trim () || '客户名称待补充' ))
return [... customers ]. map (([ value , label ]) => ({ value , label }))
})
const availablePositionOptions = computed (() => positionOptions . value . filter (({ balance }) => balance . customerId === selectedCustomerId . value ))
2026-09-15 15:24:59 +08:00
const warehouseOptions = computed (() => warehouseOptionsSource . value . filter (( item ) => item . status === 'ACTIVE' && item . warehouseType === 'OVERSEAS' ). map (( item ) => ({ value : String ( item . id ), label : [ item . warehouseName || '仓库名称待补充' , item . warehouseCode ]. filter ( Boolean ). join ( ' · ' ) })))
2026-09-10 15:33:54 +08:00
const selectedReturnStock = computed (() => returnStockRows . value . filter (( item ) => Number ( item . availableQuantity || 0 ) > 0 ))
const selectedReturnCartons = computed (() => returnCartonRows . value . filter (( item ) => item . status === 'AVAILABLE' ))
const queryTokens = ( value : string ) => value . split ( /[,, \n]+/ ). map (( item ) => item . replace ( /\s+/g , '' ). toLowerCase ()). filter ( Boolean )
const matchesAnyToken = ( value : string , query : string ) => { const tokens = queryTokens ( query ); const normalized = value . replace ( /\s+/g , '' ). toLowerCase (); return ! tokens . length || tokens . some (( token ) => normalized . includes ( token )) }
const filteredReturnStocks = computed (() => selectedReturnStock . value . filter (( item ) => matchesAnyToken ( ` ${ item . skuCode } , ${ item . productNameZh || '' } ` , returnSkuQuery . value )))
const filteredReturnCartons = computed (() => selectedReturnCartons . value . filter (( item ) => {
const detailSkus = returnCartonDetails . value [ item . cartonMark ] ? . items ? . map (( detail ) => detail . skuCode ). join ( ',' ) || ''
return matchesAnyToken ( ` ${ item . cartonMark } , ${ item . fbaCartonMark || '' } , ${ detailSkus } ` , returnCartonQuery . value )
}))
const pagedReturnStocks = computed (() => filteredReturnStocks . value . slice (( returnSkuPage . value - 1 ) * returnSkuPageSize . value , returnSkuPage . value * returnSkuPageSize . value ))
const pagedReturnCartons = computed (() => filteredReturnCartons . value . slice (( returnCartonPage . value - 1 ) * returnCartonPageSize . value , returnCartonPage . value * returnCartonPageSize . value ))
const effectiveReturnMarks = computed (() => [... new Set ( selectedReturnMarks . value )]. filter ( Boolean ) as string [])
2026-09-09 13:35:43 +08:00
const returnMarksModel = computed ({ get : () => selectedReturnMarks . value , set : ( value : string []) => { selectedReturnMarks . value = value } })
2026-09-10 15:33:54 +08:00
const filteredDropshipCandidates = computed (() => dropshipCandidates . value . filter ( item => ! dropshipQuery . value . trim () || [ item . orderNo , item . inboundCode , item . customerName , item . palletNo , item . skuCode , item . productName ]. join ( ' ' ). toLowerCase (). includes ( dropshipQuery . value . trim (). toLowerCase ())))
const pagedDropshipCandidates = computed (() => filteredDropshipCandidates . value . slice (( dropshipPage . value - 1 ) * dropshipPageSize . value , dropshipPage . value * dropshipPageSize . value ))
2026-09-15 15:24:59 +08:00
const selectedDropshipKeys = computed (() => new Set ( selectedDropshipIds . value ))
const selectedPageCount = computed (() => pagedDropshipCandidates . value . filter ( item => selectedDropshipKeys . value . has ( dropshipCandidateKey ( item ))). length )
const allPageSelected = computed (() => pagedDropshipCandidates . value . length > 0 && selectedPageCount . value === pagedDropshipCandidates . value . length )
const somePageSelected = computed (() => selectedPageCount . value > 0 && ! allPageSelected . value )
const selectedDropshipCustomerCount = computed (() => new Set ( dropshipCandidates . value . filter ( item => selectedDropshipKeys . value . has ( dropshipCandidateKey ( item ))). map ( item => item . customerId ). filter ( Boolean )). size )
2026-09-09 13:35:43 +08:00
const oneClickMarks = computed (() => typeof cartonMarks . value === 'string' ? cartonMarks . value . split ( /[,\n, ]+/ ). map ( value => value . trim ()). filter ( Boolean ) : [])
2026-09-10 15:33:54 +08:00
const columns : DataTableColumn [] = [{ key : 'actions' , label : '操作' , width : 180 , fixed : 'left' , slotName : 'actions' }, { key : 'outboundNo' , prop : 'outboundNo' , label : '出库单号' , minWidth : 180 }, { key : 'outboundType' , prop : 'outboundType' , label : '出库类型' , width : 150 , slotName : 'outboundType' }, { key : 'lxCodes' , prop : 'lxCodes' , label : 'LX代码' , minWidth : 180 }, { key : 'dispatchNo' , prop : 'dispatchNo' , label : '关联排车单' , minWidth : 160 }, { key : 'status' , prop : 'status' , label : '状态' , width : 130 , slotName : 'status' }, { key : 'palletCount' , prop : 'palletCount' , label : '托盘数' , width : 100 }, { key : 'packageCount' , prop : 'packageCount' , label : '箱数' , width : 100 }, { key : 'createdAt' , prop : 'createdAt' , label : '创建时间' , minWidth : 180 }]
const fields : QueryField [] = [{ key : 'documentNo' , type : 'input' , label : '出库单号' , placeholder : '请输入出库单号' , width : 220 }, { key : 'lxCode' , type : 'input' , label : 'LX代码' , placeholder : '请输入领星入库单代码' , width : 220 }, { key : 'warehouseId' , type : 'input' , label : '目的仓' , placeholder : '请输入目的仓' , width : 220 }, { key : 'status' , type : 'select' , label : '状态' , placeholder : '全部状态' , width : 160 , options : [{ label : '全部' , value : '' }, { label : '待出库' , value : 'WAIT_OUTBOUND' }, { label : '已出库' , value : 'OUTBOUNDED' }] }, { key : 'outboundType' , type : 'select' , label : '出库类型' , placeholder : '全部出库类型' , width : 170 , options : [{ label : '全部' , value : '' }, { label : '一件代发出库' , value : 'ONE_CLICK_DROPSHIP' }, { label : '退货出库' , value : 'RETURN_OUTBOUND' }, { label : '派送出库' , value : 'DISPATCH_DELIVERY' }] }]
2026-08-26 15:48:34 +08:00
2026-09-10 15:33:54 +08:00
async function load ( model : QueryModel = filters . value ) { Object . assign ( filters . value , model ); loading . value = true ; error . value = null ; try { const result = await listOutbounds ({ page : page . value , size : pageSize . value , documentNo : String ( filters . value . documentNo || '' ). trim () || undefined , lxCode : String ( filters . value . lxCode || '' ). trim () || undefined , warehouseId : String ( filters . value . warehouseId || '' ). trim () || undefined , status : String ( filters . value . status || '' ). trim () || undefined , outboundType : String ( filters . value . outboundType || '' ). trim () || undefined }); rows . value = result . records . map (( row ) => ({ ... row , createdAt : formatOverseasDateTime ( row . createdAt ) })); total . value = result . total } catch ( cause ) { error . value = getApiErrorDetails ( cause , '出库单列表加载失败' ) } finally { loading . value = false } }
function outboundTypeLabel ( value ?: string | null ) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : value || '—' }
async function loadInventoryOptions () {
inventoryLoading . value = true
try {
const [ tenants , warehouses ] = await Promise . allSettled ([
listPlatformTenants ({ page : 1 , size : 200 , status : 'ACTIVE' }),
listPlatformWarehouses ({ page : 1 , size : 200 , warehouseType : 'OVERSEAS' , status : 'ACTIVE' }),
])
if ( tenants . status === 'fulfilled' ) tenantOptions . value = tenants . value . records
else ElMessage . error ( getApiErrorDetails ( tenants . reason , '客户列表加载失败' ). message )
if ( warehouses . status === 'fulfilled' ) {
warehouseOptionsSource . value = warehouses . value . records
if ( ! selectedWarehouseId . value ) {
const lax = warehouses . value . records . find (( item ) => item . warehouseCode === 'US-LAX-01' || item . warehouseName ? . includes ( '洛杉矶' ))
selectedWarehouseId . value = String ( lax ? . id || warehouses . value . records [ 0 ] ? . id || '' )
}
} else ElMessage . error ( getApiErrorDetails ( warehouses . reason , '海外仓列表加载失败' ). message )
} finally { inventoryLoading . value = false }
}
2026-09-15 15:26:20 +08:00
async function openCreate () { createOpen . value = true ; outboundType . value = 'ONE_CLICK_DROPSHIP' ; selectedCustomerId . value = '' ; selectedWarehouseId . value = '' ; selectedDropshipIds . value = []; dropshipCandidates . value = []; dropshipQueryInput . value = '' ; dropshipQuery . value = '' ; dropshipPage . value = 1 ; returnSkuQueryInput . value = '' ; returnSkuQuery . value = '' ; returnCartonQueryInput . value = '' ; returnCartonQuery . value = '' ; returnSkuPage . value = 1 ; returnCartonPage . value = 1 ; await loadInventoryOptions (); await loadDropshipCandidates () }
2026-09-10 15:33:54 +08:00
async function loadDropshipCandidates () { if ( ! selectedWarehouseId . value ) { dropshipCandidates . value = []; return }; dropshipPage . value = 1 ; inventoryLoading . value = true ; try { dropshipCandidates . value = await listOneClickOutboundCandidates ( selectedCustomerId . value || undefined , { warehouseId : selectedWarehouseId . value }) } catch ( cause ) { dropshipCandidates . value = []; ElMessage . error ( getApiErrorDetails ( cause , '一件代发订单加载失败' ). message ) } finally { inventoryLoading . value = false } }
async function changeDropshipWarehouse () { selectedDropshipIds . value = []; await loadDropshipCandidates () }
async function selectCustomer () { selectedPositionId . value = '' ; selectedDropshipIds . value = []; selectedReturnNo . value = '' ; selectedReturnMarks . value = []; selectedReturnSkus . value = []; returnCandidates . value = []; returnStockRows . value = []; returnCartonRows . value = []; returnCartonDetails . value = {}; dropshipCandidates . value = []; dropshipPage . value = 1 ; if ( outboundType . value === 'RETURN_OUTBOUND' ) { if ( selectedCustomerId . value && selectedWarehouseId . value ) await loadReturnCandidates (); return }; await loadDropshipCandidates () }
async function changeOutboundType () { selectedReturnNo . value = '' ; selectedReturnMarks . value = []; selectedReturnSkus . value = []; returnCandidates . value = []; returnStockRows . value = []; returnCartonRows . value = []; returnCartonDetails . value = {}; selectedDropshipIds . value = []; dropshipCandidates . value = []; if ( outboundType . value === 'ONE_CLICK_DROPSHIP' ) await loadDropshipCandidates () }
async function loadReturnCandidates () { if ( ! selectedCustomerId . value || ! selectedWarehouseId . value ) return ; inventoryLoading . value = true ; try { const [ candidates , stock , cartons ] = await Promise . all ([ listReturnOutboundCandidates ( selectedCustomerId . value , selectedWarehouseId . value ), listReturnStock ({ page : 1 , size : 200 , customerId : selectedCustomerId . value , overseasWarehouseId : selectedWarehouseId . value , status : 'AVAILABLE' }), listReturnCartons ({ page : 1 , size : 200 , customerId : selectedCustomerId . value , overseasWarehouseId : selectedWarehouseId . value , status : 'AVAILABLE' })]); returnCandidates . value = candidates ; returnStockRows . value = stock . records ; returnCartonRows . value = cartons . records ; const details = await Promise . all ( cartons . records . map ( async ( row ) => [ row . cartonMark , await getReturnCarton ( row . cartonMark )] as const )); returnCartonDetails . value = Object . fromEntries ( details ); selectedReturnSkus . value = []; selectedReturnMarks . value = [] } catch ( cause ) { ElMessage . error ( getApiErrorDetails ( cause , '客户库存加载失败' ). message ) } finally { inventoryLoading . value = false } }
function dropshipCandidateKey ( item : OneClickOutboundCandidate ) { return ` ${ item . inventoryPositionId } : ${ item . skuCode || '' } ` }
function isDropshipSelected ( item : OneClickOutboundCandidate ) { return selectedDropshipIds . value . includes ( dropshipCandidateKey ( item )) }
function toggleDropshipCandidate ( item : OneClickOutboundCandidate , checked : boolean | string | number ) { const key = dropshipCandidateKey ( item ); selectedDropshipIds . value = checked ? [... new Set ([... selectedDropshipIds . value , key ])] : selectedDropshipIds . value . filter ( value => value !== key ) }
2026-09-15 15:24:59 +08:00
function selectDropshipRows ( items : OneClickOutboundCandidate [], checked : boolean | string | number ) {
const keys = new Set ( items . map ( dropshipCandidateKey ))
selectedDropshipIds . value = checked
? [... new Set ([... selectedDropshipIds . value , ... keys ])]
: selectedDropshipIds . value . filter ( key => ! keys . has ( key ))
}
2026-09-10 15:33:54 +08:00
function searchDropshipCandidates () { dropshipQuery . value = dropshipQueryInput . value . trim (); dropshipPage . value = 1 }
function resetDropshipCandidates () { dropshipQueryInput . value = '' ; dropshipQuery . value = '' ; dropshipPage . value = 1 }
function normalizeBatchQuery ( value : string ) { return value . split ( /[,, \n]+/ ). map (( item ) => item . replace ( /\s+/g , '' )). filter ( Boolean ). join ( ',' ) }
function searchReturnSkus () { returnSkuQueryInput . value = normalizeBatchQuery ( returnSkuQueryInput . value ); returnSkuQuery . value = returnSkuQueryInput . value ; returnSkuPage . value = 1 }
function searchReturnCartons () { returnCartonQueryInput . value = normalizeBatchQuery ( returnCartonQueryInput . value ); returnCartonQuery . value = returnCartonQueryInput . value ; returnCartonPage . value = 1 }
function toggleReturnSku ( sku : string , checked : boolean | string | number ) { selectedReturnSkus . value = checked ? [... new Set ([... selectedReturnSkus . value , sku ])] : selectedReturnSkus . value . filter (( value ) => value !== sku ) }
2026-09-10 20:38:06 +08:00
function toggleReturnCarton ( cartonMark : string , checked : boolean | string | number ) { selectedReturnMarks . value = checked ? [... new Set ([... selectedReturnMarks . value , cartonMark ])] : selectedReturnMarks . value . filter (( value ) => value !== cartonMark ) }
2026-09-10 15:33:54 +08:00
async function submit () { const marks = String ( cartonMarks . value ). split ( /[,\n, ]+/ ). map ( value => value . trim ()). filter ( Boolean ); if ( ! selectedWarehouseId . value ) { ElMessage . warning ( '请选择海外仓' ); return }; if ( outboundType . value === 'RETURN_OUTBOUND' && ! selectedCustomerId . value ) { ElMessage . warning ( '请选择客户' ); return }; saving . value = true ; try { if ( outboundType . value === 'RETURN_OUTBOUND' ) { if ( ! recipient . value . trim () || ! selectedReturnNo . value || ! selectedReturnMarks . value . length ) { ElMessage . warning ( '请填写收件信息并选择退货单和箱唛' ); return }; await createReturnOutbound ( selectedCustomerId . value , { recipient : recipient . value . trim (), overseasWarehouseId : selectedWarehouseId . value , returnNo : selectedReturnNo . value , cartonMarks : selectedReturnMarks . value , remark : remark . value . trim () || undefined , idempotencyKey : `web-return- ${ Date . now () } ` }); ElMessage . success ( '退货出库单已创建并推送PDA箱唛拣货任务' ) } else { const selected = dropshipCandidates . value . filter ( item => selectedDropshipIds . value . includes ( dropshipCandidateKey ( item ))); if ( ! selected . length ) { ElMessage . warning ( '请至少选择一个已完成入库的一件代发订单' ); return }; const customerIds = new Set ( selected . map ( item => item . customerId ). filter ( Boolean )); if ( customerIds . size !== 1 ) { ElMessage . warning ( '一张出库单只能选择同一个客户的订单' ); return }; const warehouses = new Set ( selected . map ( item => item . overseasWarehouseId ). filter ( Boolean )); if ( warehouses . size > 1 || ( warehouses . size === 1 && ! warehouses . has ( selectedWarehouseId . value ))) { ElMessage . warning ( '所选订单必须属于当前海外仓' ); return }; const items = selected . map ( item => ({ inventoryPositionId : item . inventoryPositionId , packages : item . availablePackages , sku : item . skuCode || '' , cartonMarks : [] as string [] })); await createOneClickDropshipOutbound ([... customerIds ][ 0 ] || '' , { recipient : '一件代发订单直出' , items , remark : remark . value . trim () || undefined , idempotencyKey : `web-dropship- ${ Date . now () } ` }); ElMessage . success ( `已按订单生成 ${ selected . length } 个托盘出库明细并推送PDA拣货任务` ) } createOpen . value = false ; await load () } catch ( cause ) { ElMessage . error ( getApiErrorDetails ( cause , '出库单创建失败' ). message ) } finally { saving . value = false } }
2026-09-01 14:23:53 +08:00
function openDetail ( row : OutboundRow ) { void router . push ({ name : 'overseas-outbound-detail' , params : { outboundNo : row . outboundNo } }) }
function canCancel ( row : OutboundRow ) { return access . can ( permission . outboundCancel ) && row . status === 'PICKED' }
2026-09-14 20:03:27 +08:00
async function cancel ( row : OutboundRow ) { if ( ! canCancel ( row )) return ; let reason = '' ; try { const result = await ElMessageBox . prompt ( `出库单 ${ row . outboundNo } · 出库数量 ${ row . packageCount ?? 0 } ` , '取消出库单' , { customClass : 'admin-density-confirm' , inputType : 'textarea' , inputPlaceholder : '请输入取消原因' , inputPattern : /\S+/ , inputErrorMessage : '请输入取消原因' , confirmButtonText : '确认取消' , cancelButtonText : '返回' , type : 'warning' }); reason = result . value . trim () } catch { return }; mutationError . value = null ; try { await cancelOutbound ( row . outboundNo , reason ); ElMessage . success ( '出库单已取消,库存及关联单据已回滚' ); await load () } catch ( cause ) { mutationError . value = getApiErrorDetails ( cause , '取消出库单失败' ) } }
2026-08-26 15:48:34 +08:00
function search ( model : QueryModel ) { page . value = 1 ; void load ( model ) }
function reset ( model : QueryModel ) { page . value = 1 ; void load ( model ) }
onMounted ( load )
2026-08-25 20:57:51 +08:00
</ script >
2026-08-26 15:48:34 +08:00
< template >
2026-09-14 20:03:27 +08:00
< AdminPage class = "admin-density" >< ListPageLayout aria -label =" 出库单 " >
2026-09-01 14:23:53 +08:00
< OperationErrorToast :details = "mutationError" />
2026-09-07 11:15:37 +08:00
< template # toolbar >< QueryToolbar v-model = "filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></ template >
2026-09-01 14:23:53 +08:00
< template v-if = "error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState ></ template >
2026-08-26 15:48:34 +08:00
< section v-if = "!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()" >
2026-09-09 13:35:43 +08:00
< template # toolbar -actions >< el-button v-if = "access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" /> 新建出库单 </ el-button ></ template >
2026-09-14 20:03:27 +08:00
< template # actions = "{ row }" >< div class = "density-actions" >< el-button class = "density-action" data -tone =" neutral " size = "small" @click ="openDetail(row as OutboundRow)" > 详情 </ el -button >< el-button class = "density-action" data -tone =" danger " v-if = "canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)" > 取消 </ el -button ></ div ></ template >
2026-09-10 15:33:54 +08:00
< template # outboundType = "{ row }" >{{ outboundTypeLabel ( row . outboundType ) }}</ template >
2026-09-15 18:17:17 +08:00
< template # status = "{ row }" >< StatusBadge : status = "row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : '未知状态'" /></ template >
2026-08-26 15:48:34 +08:00
</ DataTable ></ section >
</ ListPageLayout ></ AdminPage >
2026-09-14 20:03:27 +08:00
< AppDialog class = "admin-density-dialog" v-model = "createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit" >
< el-form class = "create-outbound-form density-form" label -position =" top " >
2026-09-10 15:33:54 +08:00
< el-form-item label = "出库类型" required >
< el-radio-group v-model = "outboundType" @change="changeOutboundType" >
< el-radio-button label = "ONE_CLICK_DROPSHIP" > 一件代发出库 </ el-radio-button >
< el-radio-button label = "RETURN_OUTBOUND" > 退货出库 </ el-radio-button >
</ el-radio-group >
</ el-form-item >
< el-form-item v-if = "outboundType === 'RETURN_OUTBOUND'" label="客户" required >
< el-select v-model = "selectedCustomerId" :loading="inventoryLoading" clearable filterable placeholder="请选择客户" @change="selectCustomer" >
< el-option v-for = "item in customerOptions" :key="item.value" :value="item.value" :label="item.label" />
</ el-select >
</ el-form-item >
< template v-if = "outboundType !== 'RETURN_OUTBOUND'" >
2026-09-15 18:17:17 +08:00
< div class = "dropship-search-row" >
2026-09-10 15:33:54 +08:00
< el-form-item label = "海外仓" required >
2026-09-15 15:24:59 +08:00
< el-select v-model = "selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse" >
2026-09-10 15:33:54 +08:00
< el-option v-for = "item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
</ el-select >
</ el-form-item >
2026-09-15 18:17:17 +08:00
< el-form-item label = "一件代发订单" >
2026-09-10 15:33:54 +08:00
< div class = "candidate-query" >
2026-09-15 18:17:17 +08:00
< el-input v-model = "dropshipQueryInput" :prefix-icon="Search" clearable aria-label="搜索可出库订单" placeholder="订单号 / 入库单号 / 客户 / 托盘 / SKU / 品名" @keyup.enter="searchDropshipCandidates" />
2026-09-10 15:33:54 +08:00
< el-button type = "primary" :loading = "inventoryLoading" @click ="searchDropshipCandidates" > 查询 </ el -button >
< el-button @click ="resetDropshipCandidates" > 重置 </ el -button >
</ div >
2026-09-15 18:17:17 +08:00
</ el-form-item >
</ div >
< section class = "candidate-results" aria -label =" 已完成入库的一件代发订单 " >
2026-09-15 15:24:59 +08:00
< el-table v-loading = "inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table" max-height="360" >
2026-09-10 15:33:54 +08:00
< el-table-column label = "选择" width = "68" align = "center" >
2026-09-15 15:24:59 +08:00
< template # header >< el-checkbox aria -label =" 全选本页订单 " :model-value = "allPageSelected" :indeterminate = "somePageSelected" : disabled = "inventoryLoading || !pagedDropshipCandidates.length" @change ="selectDropshipRows(pagedDropshipCandidates, $event)" /></ template >
< template # default = "{ row }" >< el-checkbox : aria -label ="` 选择 $ { row.orderNo || ' 订单 ' } / $ { row.palletNo || ' 托盘 ' } / $ { row.skuCode || ' SKU ' } `" : model -value =" isDropshipSelected ( row as OneClickOutboundCandidate )" @change ="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></ template >
2026-09-10 15:33:54 +08:00
</ el-table-column >
< el-table-column prop = "customerName" label = "客户" min -width =" 150 " show -overflow-tooltip />
< el-table-column prop = "orderNo" label = "尾程订单号" min -width =" 170 " show -overflow-tooltip />
< el-table-column prop = "inboundCode" label = "入库单号" min -width =" 160 " show -overflow-tooltip />
< el-table-column prop = "palletNo" label = "托盘号" min -width =" 140 " show -overflow-tooltip />
< el-table-column prop = "skuCode" label = "SKU" min -width =" 140 " show -overflow-tooltip />
< el-table-column prop = "productName" label = "品名" min -width =" 140 " show -overflow-tooltip />
< el-table-column prop = "availablePackages" label = "可出箱数" width = "95" align = "right" />
</ el-table >
< div v-if = "!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint" > 暂无可直接出库的已完成入库订单 </ div >
2026-09-15 18:17:17 +08:00
< div class = "candidate-pagination" >
< span aria -live =" polite " title = "翻页保留选择" > 已选择 {{ selectedDropshipIds . length }} 条 · 第 {{ dropshipPage }} / {{ Math . max ( 1 , Math . ceil ( filteredDropshipCandidates . length / dropshipPageSize )) }} 页 </ span >
2026-09-15 15:24:59 +08:00
< el-pagination v -model:current-page = "dropshipPage" v -model:page-size = "dropshipPageSize" : page -sizes ="[ 5 , 10 , 20 , 50 ]" :pager-count = "5" :total = "filteredDropshipCandidates.length" layout = "total, sizes, prev, pager, next, jumper" @ size -change =" dropshipPage = 1 " />
2026-09-10 15:33:54 +08:00
</ div >
2026-09-15 15:24:59 +08:00
< el-alert v-if = "selectedDropshipCustomerCount > 1" title="当前选择包含多个客户,请按客户搜索后选择;一张出库单只能属于同一客户。" type="warning" :closable="false" />
2026-09-15 18:17:17 +08:00
< p v-else class = "candidate-hint" > 按托盘与 SKU 选择 , 默认出库全部可用箱数 ; 仅限同一客户 、 同一海外仓 , 翻页保留选择 。 </ p >
</ section >
2026-09-10 15:33:54 +08:00
</ template >
< template v-else >
< el-form-item label = "收件信息" required >< el-input v-model = "recipient" placeholder="请输入收件人、电话和地址" /></ el-form-item >
< el-form-item label = "海外仓" required >< el-select v-model = "selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></ el-select ></ el-form-item >
< div class = "return-lists" >
< section class = "return-list-card" >
< div class = "list-title" >< span > SKU库存 </ span >< small > 支持多个 SKU , 用英文逗号分隔 </ small ></ div >
< div class = "candidate-query" >< el-input v-model = "returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus" > 查询 </ el -button ></ div >
< el-table :data = "pagedReturnStocks" border stripe class = "return-result-table" max -height =" 400 " >< el-table-column label = "选择" width = "60" align = "center" >< template # default = "{ row }" >< el-checkbox :model-value = "selectedReturnSkus.includes(row.skuCode)" @change ="toggleReturnSku(row.skuCode, $event)" /></ template ></ el-table-column >< el-table-column prop = "skuCode" label = "SKU" min -width =" 150 " show -overflow-tooltip />< el-table-column prop = "productNameZh" label = "品名" min -width =" 150 " show -overflow-tooltip />< el-table-column prop = "availableQuantity" label = "可用数量" width = "100" align = "right" /></ el-table >
< div class = "return-pagination" >< span > 已选择 {{ selectedReturnSkus . length }} 个 SKU </ span >< el-pagination v -model:current-page = "returnSkuPage" v -model:page-size = "returnSkuPageSize" : page -sizes ="[ 5 , 10 , 20 , 50 ]" :total = "filteredReturnStocks.length" layout = "total, sizes, prev, pager, next" @ size -change =" returnSkuPage = 1 " /></ div >
</ section >
< section class = "return-list-card" >
< div class = "list-title" >< span > 箱号列表 </ span >< small > 支持多个箱号或 SKU , 用英文逗号分隔 </ small ></ div >
< div class = "candidate-query" >< el-input v-model = "returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons" > 查询 </ el -button ></ div >
2026-09-10 20:38:06 +08:00
< el-table :data = "pagedReturnCartons" border stripe class = "return-result-table" max -height =" 400 " >< el-table-column label = "选择" width = "60" align = "center" >< template # default = "{ row }" >< el-checkbox :model-value = "selectedReturnMarks.includes(row.cartonMark)" @change ="toggleReturnCarton(row.cartonMark, $event)" /></ template ></ el-table-column >< el-table-column prop = "cartonMark" label = "箱号" min -width =" 190 " show -overflow-tooltip />< el-table-column label = "箱内SKU及数量" min -width =" 230 " >< template # default = "{ row }" >{{ returnCartonDetails [ row . cartonMark ] ? . items ? . map (( item ) => ` ${ item . skuCode } × ${ item . quantity } ` ). join ( ', ' ) || '加载中' }}</ template ></ el-table-column ></ el-table >
2026-09-10 15:33:54 +08:00
< div class = "return-pagination" >< span > 已选择 {{ selectedReturnMarks . length }} 个箱号 </ span >< el-pagination v -model:current-page = "returnCartonPage" v -model:page-size = "returnCartonPageSize" : page -sizes ="[ 5 , 10 , 20 , 50 ]" :total = "filteredReturnCartons.length" layout = "total, sizes, prev, pager, next" @ size -change =" returnCartonPage = 1 " /></ div >
</ section >
</ div >
< el-alert title = "退货出库支持按客户选择单个或多个箱子;SKU数量直接从所选箱唛明细扣减。" type = "info" :closable = "false" />
</ template >
2026-09-15 18:17:17 +08:00
< el-form-item label = "备注" >< el-input v-model = "remark" type="textarea" :autosize="{ minRows: 1, maxRows: 3 }" placeholder="请输入备注(选填)" /></ el-form-item >
2026-09-10 15:33:54 +08:00
</ el-form >
</ AppDialog >
2026-08-26 15:48:34 +08:00
</ template >
2026-09-02 15:43:11 +08:00
< style scoped >
2026-09-14 20:03:27 +08:00
. create - outbound - form { padding : 0 var ( -- admin - space - 1 ); }
2026-09-15 15:24:59 +08:00
. create - outbound - form : deep (. el - form - item__content > . el - input ), . create - outbound - form : deep (. el - form - item__content > . el - select ), . create - outbound - form : deep (. el - form - item__content > . el - input - number ) { width : 100 % ; }
2026-09-14 20:03:27 +08:00
. form - hint { margin - top : var ( -- admin - space - 2 ); color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); }
. candidate - list { display : grid ; gap : var ( -- admin - space - 3 ); max - height : 300 px ; overflow : auto ; padding : var ( -- admin - space - 3 ) 0 ; }
2026-09-10 15:33:54 +08:00
. candidate - list : deep (. el - checkbox ) { margin - right : 0 ; }
2026-09-14 20:03:27 +08:00
. candidate - row { display : grid ; grid - template - columns : minmax ( 120 px , 1 fr ) minmax ( 150 px , 1 fr ) auto ; gap : var ( -- admin - space - 5 ); align - items : center ; width : min ( 700 px , 100 % ); }
. candidate - row span , . candidate - row em { color : var ( -- cc - muted ); font - style : normal ; font - size : var ( -- admin - font - caption ); }
. candidate - query { display : flex ; width : 100 % ; gap : var ( -- admin - space - 4 ); }
2026-09-10 15:33:54 +08:00
. candidate - query : deep (. el - input ) { flex : 1 ; }
. candidate - query : deep (. el - button ) { flex : 0 0 auto ; min - width : 78 px ; margin - left : 0 ; }
2026-09-15 18:17:17 +08:00
. dropship - search - row { display : grid ; grid - template - columns : minmax ( 0 , 280 px ) minmax ( 0 , 640 px ); align - items : end ; gap : var ( -- admin - space - 5 ); }
. dropship - search - row : deep (. el - form - item ) { min - width : 0 ; margin - bottom : var ( -- admin - space - 3 ); }
. candidate - results { min - width : 0 ; }
. candidate - hint { margin : 0 0 var ( -- admin - space - 3 ); color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); line - height : 1.5 ; }
. dropship - candidate - table { width : 100 % ; }
2026-09-14 20:03:27 +08:00
. candidate - pagination { display : flex ; width : 100 % ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 ); padding : var ( -- admin - space - 5 ) 0 ; color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); }
2026-09-15 15:24:59 +08:00
. candidate - pagination , . return - pagination { flex - wrap : wrap ; }
. candidate - pagination > span , . return - pagination > span { flex - shrink : 0 ; white - space : nowrap ; }
. candidate - pagination : deep (. el - pagination ), . return - pagination : deep (. el - pagination ) { min - width : 0 ; max - width : 100 % ; flex - wrap : wrap ; gap : var ( -- admin - space - 2 ); justify - content : flex - end ; }
. candidate - pagination : deep (. el - pagination__sizes ), . return - pagination : deep (. el - pagination__sizes ) { flex : 0 0 128 px ; }
. candidate - pagination : deep (. el - pagination__sizes . el - select ), . return - pagination : deep (. el - pagination__sizes . el - select ) { width : 128 px ; }
. candidate - pagination : deep (. el - pagination__jump . el - input ) { width : 56 px ; }
2026-09-14 20:03:27 +08:00
. return - lists { display : grid ; grid - template - columns : minmax ( 0 , 1 fr ) minmax ( 0 , 1 fr ); gap : var ( -- admin - space - 5 ); width : 100 % ; margin : var ( -- admin - space - 1 ) 0 var ( -- admin - space - 5 ); }
. return - list - card { min - width : 0 ; padding : var ( -- admin - space - 5 ); border : 1 px solid var ( -- el - border - color - light ); border - radius : var ( -- admin - radius ); background : var ( -- el - fill - color - extra - light ); }
. list - title { display : flex ; align - items : baseline ; justify - content : space - between ; gap : var ( -- admin - space - 5 ); margin - bottom : var ( -- admin - space - 5 ); }
. list - title span { color : var ( -- el - text - color - primary ); font - size : var ( -- admin - font - section ); font - weight : 650 ; }
. list - title small { color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); font - weight : 400 ; }
. return - result - table { width : 100 % ; margin - top : var ( -- admin - space - 5 ); background : var ( -- el - bg - color ); }
. return - pagination { display : flex ; min - height : 44 px ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 ); padding - top : var ( -- admin - space - 5 ); color : var ( -- cc - muted ); font - size : var ( -- admin - font - caption ); }
2026-09-10 15:33:54 +08:00
. return - pagination : deep (. el - pagination ) { flex - wrap : wrap ; justify - content : flex - end ; }
@ media ( max - width : 980 px ) { . return - lists { grid - template - columns : 1 fr ; }. return - pagination { align - items : flex - start ; flex - direction : column ; } }
2026-09-02 15:43:11 +08:00
</ style >