2026-08-13 11:57:44 +08:00
< script setup lang = "ts" >
2026-08-13 19:38:20 +08:00
import { computed , onActivated , onBeforeUnmount , onMounted , ref , watch } from 'vue'
2026-08-13 11:57:44 +08:00
import { useRouter } from 'vue-router'
2026-08-17 18:00:42 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
import { CircleCheckBig , Eye , Ruler , SearchCheck } from '@lucide/vue'
2026-08-13 11:57:44 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
import { listPlatformWarehouses } from '@/modules/warehouses'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { listPlatformInbounds } from '../core/api'
import type { InboundOrderSummary } from '../core/types'
2026-08-17 17:49:10 +08:00
import InboundMeasurementDialog from '../measurement/InboundMeasurementDialog.vue'
2026-08-13 11:57:44 +08:00
import MeasurementReviewPanel from '../review/MeasurementReviewPanel.vue'
2026-08-17 18:00:42 +08:00
import { approveMeasurement , getMeasurementReviewDetail } from '../review/api'
2026-08-13 11:57:44 +08:00
import { inboundProcessStatusLabels , inboundReviewStatusLabels , inboundStatusLabel } from '../status'
2026-08-14 14:27:03 +08:00
import { buildInboundTaskQuery , loadAllActiveCustomers , type InboundTaskMode } from '../task-list'
2026-08-13 11:57:44 +08:00
type FilterModel = QueryModel & {
customerId : string
inboundCode : string
warehouseId : string
expectedArrivalRange : string []
}
const props = defineProps < { mode : InboundTaskMode } > ()
const router = useRouter ()
const access = useAccessPreviewStore ()
const page = ref ( 1 )
const pageSize = ref ( 20 )
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const result = ref ({ records : [] as InboundOrderSummary [], total : 0 })
2026-08-14 19:59:32 +08:00
const filters = ref < FilterModel >({ customerId : '' , inboundCode : '' , warehouseId : '' , expectedArrivalRange : [] })
2026-08-13 11:57:44 +08:00
const appliedFilters = ref < FilterModel >({ ... filters . value , expectedArrivalRange : [] })
const customers = ref < PlatformTenantResource [] >([])
const customerLoadError = ref < ApiErrorDetails | null >( null )
const warehouseOptions = ref < Array < { label : string ; value : string }> > ([])
const selectedInbound = ref < InboundOrderSummary | null >( null )
2026-08-17 17:49:10 +08:00
const measurementDialogOpen = ref ( false )
const reviewDialogOpen = ref ( false )
2026-08-17 18:00:42 +08:00
const batchReviewLoading = ref ( false )
const selectedReviewRows = ref < InboundOrderSummary [] >([])
2026-08-13 11:57:44 +08:00
let requestSerial = 0
2026-08-14 14:27:03 +08:00
let optionsRequestSerial = 0
2026-08-13 11:57:44 +08:00
let disposed = false
2026-08-13 19:38:20 +08:00
let activatedOnce = false
2026-08-13 11:57:44 +08:00
const isReview = computed (() => props . mode === 'review' )
const actionPermission = computed (() => isReview . value
? 'firstleg:inbound:measurement-review'
: 'platform:firstleg:inbound:measure:view' )
const taskLabel = computed (() => isReview . value ? '审核' : '测量' )
const emptyText = computed (() => isReview . value ? '暂无待审核的测量数据' : '暂无当前可测量的订单' )
const preferenceKey = computed (() => isReview . value ? 'inbound-measurement-reviews' : 'inbound-measurements' )
const queryFields = computed < QueryField [] >(() => [
{
key : 'customerId' ,
type : 'select' ,
label : '客户' ,
width : 260 ,
placeholder : customerLoadError . value ? '客户列表加载失败' : '全部客户' ,
options : customers . value . map (( customer ) => ({
value : String ( customer . id ),
2026-08-14 12:50:42 +08:00
label : customer . legalName ? . trim () || customer . tenantName ,
2026-08-13 11:57:44 +08:00
})),
props : { filterable : true , disabled : Boolean ( customerLoadError . value ) },
},
{ key : 'warehouseId' , type : 'select' , label : '定位仓库' , width : 220 , placeholder : '全部仓库' , options : warehouseOptions . value , props : { filterable : true } },
{ key : 'inboundCode' , type : 'input' , label : '入仓码' , width : 220 , placeholder : '请输入入仓码' },
{ key : 'expectedArrivalRange' , type : 'daterange' , label : '预计到达时间' , width : 320 , placeholder : '请选择日期范围' },
])
2026-08-17 18:00:42 +08:00
const columns = computed < DataTableColumn [] >(() => [
2026-08-13 11:57:44 +08:00
{ key : 'actions' , label : '操作' , width : 190 , fixed : 'left' , slotName : 'actions' },
2026-08-17 18:00:42 +08:00
...( isReview . value && access . can ( actionPermission . value ) ? [{
key : 'selection' , type : 'selection' , width : 48 , fixed : 'left' ,
selectable : ( row : InboundOrderSummary ) => row . measureReviewStatus === 'PENDING' && ! batchReviewLoading . value ,
} satisfies DataTableColumn ] : []),
2026-08-13 11:57:44 +08:00
{ key : 'inboundCode' , label : '入仓码' , minWidth : 170 , slotName : 'inboundCode' },
{ key : 'processStatus' , label : '流程状态' , minWidth : 132 , slotName : 'processStatus' },
{ key : 'measureReviewStatus' , label : '测量审核状态' , minWidth : 142 , slotName : 'measureReviewStatus' },
{ key : 'expectedArrivalAt' , label : '预计到达时间' , minWidth : 164 , slotName : 'expectedArrivalAt' },
{ key : 'customerName' , label : '客户' , minWidth : 160 , slotName : 'customerName' },
{ key : 'warehouseName' , label : '仓库' , minWidth : 140 , slotName : 'warehouseName' },
{ key : 'deliveryWarehouseName' , label : '交货仓' , minWidth : 140 , slotName : 'deliveryWarehouseName' },
{ key : 'businessType' , label : '业务类型' , minWidth : 120 , slotName : 'businessType' },
{ key : 'channelName' , label : '渠道' , minWidth : 140 , slotName : 'channelName' },
{ key : 'totalPackageCount' , label : '总箱数' , width : 92 , slotName : 'totalPackageCount' },
{ key : 'actualGrossWeightKg' , label : '实测毛重(kg)' , minWidth : 128 , slotName : 'actualGrossWeightKg' },
{ key : 'actualVolumeCbm' , label : '实测体积(m³)' , minWidth : 128 , slotName : 'actualVolumeCbm' },
{ key : 'measureCompletedAt' , label : '测量完成时间' , minWidth : 164 , slotName : 'measureCompletedAt' },
2026-08-17 18:00:42 +08:00
])
2026-08-13 11:57:44 +08:00
function text ( value : unknown ) {
return value === null || value === undefined || value === '' ? '—' : String ( value )
}
function formatNumber ( value : number | null | undefined ) {
return value === null || value === undefined ? '—' : value . toLocaleString ( 'zh-CN' , { maximumFractionDigits : 3 })
}
function formatDate ( value : string | null | undefined ) {
if ( ! value ) return '—'
const date = new Date ( value )
return Number . isNaN ( date . getTime ()) ? value : date . toLocaleString ( 'zh-CN' , { hour12 : false })
}
function dateParam ( value : string | undefined , end = false ) {
return value ? ` ${ value } T ${ end ? '23:59:59' : '00:00:00' } ` : undefined
}
async function loadOptions () {
2026-08-14 14:27:03 +08:00
const serial = ++ optionsRequestSerial
2026-08-13 11:57:44 +08:00
customerLoadError . value = null
try {
2026-08-14 14:27:03 +08:00
const [ customerRecords , warehousePage ] = await Promise . all ([
loadAllActiveCustomers (( query ) => listPlatformTenants ( query )),
2026-08-13 11:57:44 +08:00
listPlatformWarehouses ( 1 , 200 ),
])
2026-08-14 14:27:03 +08:00
if ( disposed || serial !== optionsRequestSerial ) return
customers . value = customerRecords
2026-08-13 11:57:44 +08:00
warehouseOptions . value = warehousePage . records . map (( warehouse ) => ({
label : ` ${ warehouse . warehouseName } · ${ warehouse . warehouseCode } ` ,
value : String ( warehouse . id ),
}))
} catch ( error ) {
if ( disposed ) return
customerLoadError . value = getApiErrorDetails ( error , '客户或仓库筛选项加载失败,不影响查看全部待办' )
}
}
async function loadTasks () {
2026-08-17 18:00:42 +08:00
selectedReviewRows . value = []
2026-08-13 11:57:44 +08:00
const serial = ++ requestSerial
loading . value = true
loadError . value = null
const applied = appliedFilters . value
try {
const response = await listPlatformInbounds ( applied . customerId || undefined , buildInboundTaskQuery ( props . mode , {
page : page . value ,
size : pageSize . value ,
warehouseId : applied . warehouseId || undefined ,
inboundCode : applied . inboundCode . trim () || undefined ,
expectedArrivalFrom : dateParam ( applied . expectedArrivalRange [ 0 ]),
expectedArrivalTo : dateParam ( applied . expectedArrivalRange [ 1 ], true ),
}))
if ( disposed || serial !== requestSerial ) return
result . value = { records : response . records , total : response . total }
} catch ( error ) {
if ( disposed || serial !== requestSerial ) return
loadError . value = getApiErrorDetails ( error , ` ${ taskLabel . value } 待办加载失败` )
} finally {
if ( ! disposed && serial === requestSerial ) loading . value = false
}
}
function applyFilters () {
appliedFilters . value = { ... filters . value , expectedArrivalRange : [... filters . value . expectedArrivalRange ] }
if ( page . value === 1 ) void loadTasks ()
else page . value = 1
}
function resetFilters () {
2026-08-14 19:59:32 +08:00
filters . value = { customerId : '' , inboundCode : '' , warehouseId : '' , expectedArrivalRange : [] }
2026-08-13 11:57:44 +08:00
appliedFilters . value = { ... filters . value , expectedArrivalRange : [] }
if ( page . value === 1 ) void loadTasks ()
else page . value = 1
}
function openTask ( row : InboundOrderSummary ) {
selectedInbound . value = row
2026-08-17 17:49:10 +08:00
if ( isReview . value ) reviewDialogOpen . value = true
else measurementDialogOpen . value = true
2026-08-13 11:57:44 +08:00
}
function openDetail ( row : InboundOrderSummary ) {
2026-08-13 19:00:37 +08:00
if ( ! row . orderId ) return
void router . push ({ name : 'unified-order-detail' , params : { orderId : row . orderId }, query : { tenantId : row . tenantId } })
2026-08-13 11:57:44 +08:00
}
function handleTaskChanged () {
2026-08-17 17:49:10 +08:00
reviewDialogOpen . value = false
2026-08-13 11:57:44 +08:00
selectedInbound . value = null
void loadTasks ()
}
2026-08-17 18:00:42 +08:00
function handleReviewSelectionChange ( rows : InboundOrderSummary []) {
selectedReviewRows . value = rows . filter (( row ) => row . measureReviewStatus === 'PENDING' )
}
async function batchApproveMeasurements () {
const selected = [... selectedReviewRows . value ]
if ( ! selected . length || batchReviewLoading . value ) return
batchReviewLoading . value = true
try {
await ElMessageBox . confirm (
`确认批量审核通过已勾选的 ${ selected . length } 条测量数据吗?通过后将进入待分柜。` ,
'批量审核测量数据' ,
{ confirmButtonText : '确认批量审核' , cancelButtonText : '取消' , type : 'warning' },
)
let successCount = 0
let failureCount = 0
const failures : string [] = []
for ( const row of selected ) {
try {
const detail = await getMeasurementReviewDetail ( String ( row . tenantId ), String ( row . id ))
await approveMeasurement ( String ( row . tenantId ), String ( row . id ), {
measurementId : detail . measurementId ,
expectedVersion : detail . version ,
})
successCount += 1
} catch ( error ) {
failureCount += 1
failures . push ( getApiErrorDetails ( error , ` ${ row . inboundCode || row . id } 审核失败` ). message )
}
}
await loadTasks ()
const reason = [... new Set ( failures . filter ( Boolean ))]. slice ( 0 , 3 ). join ( '; ' )
if ( ! failureCount ) ElMessage . success ( `已批量审核通过 ${ successCount } 条测量数据` )
else if ( successCount ) ElMessage . warning ( `批量审核完成:成功 ${ successCount } 条,失败 ${ failureCount } 条 ${ reason ? `。 ${ reason } ` : '' } ` )
else ElMessage . error ( `批量审核失败: ${ failureCount } 条测量数据未通过 ${ reason ? `。 ${ reason } ` : '' } ` )
} catch ( error ) {
if ( error !== 'cancel' && error !== 'close' ) ElMessage . error ( getApiErrorDetails ( error , '批量审核失败' ). message )
} finally {
batchReviewLoading . value = false
}
}
2026-08-17 11:23:31 +08:00
function handleMeasurementUpdated () {
void loadTasks ()
}
2026-08-17 17:49:10 +08:00
function handleMeasurementSubmitted () {
void loadTasks ()
}
function clearSelectedInbound () {
selectedInbound . value = null
2026-08-14 14:50:44 +08:00
}
2026-08-13 11:57:44 +08:00
watch ([ page , pageSize ], () => { void loadTasks () })
onMounted (() => { void loadOptions (); void loadTasks () })
2026-08-13 19:38:20 +08:00
onActivated (() => {
if ( ! activatedOnce ) {
activatedOnce = true
return
}
void loadTasks ()
})
2026-08-14 14:27:03 +08:00
onBeforeUnmount (() => { disposed = true ; requestSerial += 1 ; optionsRequestSerial += 1 })
2026-08-13 11:57:44 +08:00
</ script >
< template >
< AdminPage as = "div" class = "inbound-task-page" >
< ListPageLayout :aria-label = "`${taskLabel}待办`" >
< template # toolbar >
< QueryToolbar
v-model = "filters"
class = "surface-panel"
:ariaLabel = "`${taskLabel}待办筛选`"
:fields = "queryFields"
:preference-key = "`${preferenceKey}-query`"
:search-on-reset = "false"
@search ="applyFilters"
@reset ="resetFilters"
/>
</ template >
< template v-if = "customerLoadError || loadError" # state >
< div class = "state-stack" >
< el-alert v-if = "customerLoadError" type="warning" :closable="false" show -icon >
< template # title >< span >{{ customerLoadError . message }}</ span ></ template >
</ el-alert >
< LoadErrorState v-if = "loadError" :title="`${taskLabel}待办暂时无法加载`" :message="loadError.message" @retry="loadTasks" >
< template # details >< ApiErrorMeta :details = "loadError" /></ template >
</ LoadErrorState >
</ div >
</ template >
< section v-if = "!loadError" class="surface-panel table-panel" :aria-busy="loading" >
< DataTable
v -model:page = "page"
v -model:page-size = "pageSize"
:data = "result.records"
:columns = "columns"
:preference-key = "`${preferenceKey}-table`"
:loading = "loading"
:total = "result.total"
: page -sizes ="[ 20 , 50 , 100 ]"
row -key =" id "
:empty-text = "emptyText"
:aria-label = "`${taskLabel}待办列表`"
2026-08-17 18:00:42 +08:00
@ selection -change =" handleReviewSelectionChange "
2026-08-13 11:57:44 +08:00
>
2026-08-17 18:00:42 +08:00
< template v-if = "isReview && access.can(actionPermission)" # toolbar -actions >
< el-button type = "success" :loading = "batchReviewLoading" : disabled = "loading || !selectedReviewRows.length" @click ="batchApproveMeasurements" >< CircleCheckBig :size = "16" /> 批量审核通过 < span v-if = "selectedReviewRows.length" > ( {{ selectedReviewRows.length }} ) </ span > </ el -button >
</ template >
2026-08-13 11:57:44 +08:00
< template # pagination -left > 当前显示 {{ result . records . length }} 条 · 共 {{ result . total }} 条 </ template >
< template # actions = "{ row }" >
< div class = "row-actions" >
< el-button v-if = "access.can(actionPermission)" size="small" type="primary" @click="openTask(row as InboundOrderSummary)" >
< SearchCheck v-if = "isReview" :size="15" />
< Ruler v-else :size = "15" />
{{ taskLabel }}
</ el-button >
2026-08-13 19:00:37 +08:00
< el-button v-if = "access.can('platform:firstleg:inbound:view') && (row as InboundOrderSummary).orderId" size="small" plain @click="openDetail(row as InboundOrderSummary)" >
2026-08-13 11:57:44 +08:00
< Eye :size = "15" /> 查看
</ el-button >
2026-08-13 19:00:37 +08:00
< span v-if = "!access.can(actionPermission) && (!access.can('platform:firstleg:inbound:view') || !(row as InboundOrderSummary).orderId)" class="muted-action" > 无操作权限 </ span >
2026-08-13 11:57:44 +08:00
</ div >
</ template >
2026-08-14 19:59:32 +08:00
< template # inboundCode = "{ row }" >< strong >{{ text (( row as InboundOrderSummary ). inboundCode ) }}</ strong ></ template >
2026-08-13 11:57:44 +08:00
< template # processStatus = "{ row }" >< el-tag effect = "light" >{{ inboundStatusLabel (( row as InboundOrderSummary ). processStatus , inboundProcessStatusLabels ) }}</ el-tag ></ template >
< template # measureReviewStatus = "{ row }" >{{ inboundStatusLabel (( row as InboundOrderSummary ). measureReviewStatus , inboundReviewStatusLabels ) }}</ template >
< template # expectedArrivalAt = "{ row }" >{{ formatDate (( row as InboundOrderSummary ). expectedArrivalAt ) }}</ template >
< template # customerName = "{ row }" >{{ text (( row as InboundOrderSummary ). customerName ) }}</ template >
< template # warehouseName = "{ row }" >{{ text (( row as InboundOrderSummary ). warehouseName ) }}</ template >
< template # deliveryWarehouseName = "{ row }" >{{ text (( row as InboundOrderSummary ). deliveryWarehouseName ) }}</ template >
< template # businessType = "{ row }" >{{ text (( row as InboundOrderSummary ). businessType ) }}</ template >
< template # channelName = "{ row }" >{{ text (( row as InboundOrderSummary ). channelName || ( row as InboundOrderSummary ). channelCode ) }}</ template >
< template # totalPackageCount = "{ row }" >{{ text (( row as InboundOrderSummary ). totalPackageCount ) }}</ template >
< template # actualGrossWeightKg = "{ row }" >{{ formatNumber (( row as InboundOrderSummary ). actualGrossWeightKg ) }}</ template >
< template # actualVolumeCbm = "{ row }" >{{ formatNumber (( row as InboundOrderSummary ). actualVolumeCbm ) }}</ template >
< template # measureCompletedAt = "{ row }" >{{ formatDate (( row as InboundOrderSummary ). measureCompletedAt ) }}</ template >
< template # empty >< el-empty :description = "emptyText" /></ template >
</ DataTable >
</ section >
</ ListPageLayout >
< el-dialog
2026-08-17 17:49:10 +08:00
v-if = "isReview"
v-model = "reviewDialogOpen"
2026-08-13 11:57:44 +08:00
: title = "selectedInbound ? `${taskLabel} · ${selectedInbound.inboundCode || selectedInbound.id}` : taskLabel"
2026-08-13 17:14:51 +08:00
width = "min(1180px, calc(100vw - 32px))"
2026-08-13 11:57:44 +08:00
top = "4vh"
destroy -on-close
append -to-body
class = "inbound-task-dialog"
2026-08-17 17:49:10 +08:00
@closed ="clearSelectedInbound"
2026-08-13 11:57:44 +08:00
>
< MeasurementReviewPanel
2026-08-17 17:49:10 +08:00
v-if = "selectedInbound"
2026-08-13 17:14:51 +08:00
:tenant-id = "selectedInbound.tenantId"
2026-08-13 11:57:44 +08:00
:inbound-id = "selectedInbound.id"
@changed ="handleTaskChanged"
/>
</ el-dialog >
2026-08-17 17:49:10 +08:00
< InboundMeasurementDialog
v-if = "!isReview && selectedInbound"
v-model = "measurementDialogOpen"
:customer-id = "selectedInbound.tenantId"
:inbound-id = "selectedInbound.id"
:inbound-code = "selectedInbound.inboundCode"
@updated ="handleMeasurementUpdated"
@submitted ="handleMeasurementSubmitted"
@closed ="clearSelectedInbound"
/>
2026-08-13 11:57:44 +08:00
</ AdminPage >
</ template >
< style scoped >
. inbound - task - page { min - width : 0 ; }
. state - stack { display : grid ; gap : 12 px ; }
. row - actions { display : flex ; align - items : center ; gap : 8 px ; white - space : nowrap ; }
. row - actions . el - button { margin : 0 ; }
. primary - cell { display : grid ; gap : 3 px ; min - width : 0 ; }
. primary - cell strong , . primary - cell span { overflow - wrap : anywhere ; }
. primary - cell span , . muted - action { color : var ( -- cc - muted ); font - size : 12 px ; }
@ media ( max - width : 520 px ) {
. row - actions { align - items : stretch ; flex - direction : column ; }
. row - actions : deep (. el - button ) { width : 100 % ; }
}
</ style >
< style >
. inbound - task - dialog { max - height : 92 vh ; overflow : hidden ; }
. inbound - task - dialog . el - dialog__body { max - height : calc ( 92 vh - 62 px ); overflow : auto ; padding : 20 px 24 px 24 px ; }
@ media ( max - width : 520 px ) {
. inbound - task - dialog . el - dialog__body { padding : 14 px ; }
}
</ style >