2026-10-07 16:46:57 +08:00
< script setup lang = "ts" >
import '@/styles/adminDensity.css'
import { computed , onBeforeUnmount , onMounted , ref , watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
2026-10-09 22:05:51 +08:00
import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
2026-10-07 16:46:57 +08:00
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
2026-10-20 21:33:46 +08:00
import { listLooseboardDemands , listCapacityOffers , listCapacityInquiries , getCapacityOffer , getCapacityInquiry } from '../api/capacityOffers'
import { offerStatusLabels , inquiryStatusLabels , offerTime , loadText , type LooseboardDemand , type CapacityOffer , type CapacityInquiry , type InquiryResult , type OfferQuery , type InquiryQuery } from '../capacityOfferTypes'
2026-10-11 15:33:09 +08:00
import { inquirySourceQuery , inquiryOrderTarget } from '../inquiryNavigation'
2026-10-07 16:46:57 +08:00
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
const router = useRouter (), access = useAccessPreviewStore ()
2026-10-10 15:33:10 +08:00
const manage = computed (() => access . can ( 'platform:fleet:capacity-offer:manage' ))
2026-10-20 21:33:46 +08:00
const tab = ref < 'offers' | 'inquiries' | 'demands' > ( manage . value ? 'inquiries' : 'offers' )
2026-10-10 15:33:10 +08:00
const filters = ref < QueryModel >( manage . value ? { scope : 'TODO' } : {}), applied = ref < QueryModel >({ ... filters . value })
2026-10-07 16:46:57 +08:00
const page = ref ( 1 ), size = ref ( 20 ), total = ref ( 0 ), loading = ref ( false ), error = ref < ApiErrorDetails | null >( null )
const offers = ref < CapacityOffer [] >([]), inquiries = ref < CapacityInquiry [] >([])
const editorOpen = ref ( false ), editing = ref < CapacityInquiry | null >( null ), selectedOffer = ref < CapacityOffer | null >( null ), readonly = ref ( true )
2026-10-09 22:05:51 +08:00
const confirmMode = ref ( false )
2026-10-20 21:33:46 +08:00
const demands = ref < LooseboardDemand [] >([]), selectedDemand = ref < LooseboardDemand | null >( null )
const demandColumns : DataTableColumn [] = [{ key : 'actions' , label : '操作' , width : 130 , fixed : 'left' , slotName : 'actions' }, { key : 'orderNo' , label : '散板原单' , minWidth : 220 }, { key : 'customerId' , label : '客户编号' , minWidth : 140 }, { key : 'origin' , label : '起运地' , minWidth : 170 }, { key : 'destination' , label : '目的地' , minWidth : 170 }, { key : 'productName' , label : '货物' , minWidth : 160 }, { key : 'estimatedPalletCount' , label : '预计实托' , width : 110 }, { key : 'packageCount' , label : '箱数' , width : 100 }]
function matchDemand ( row : LooseboardDemand ) { selectedDemand . value = row ; switchTab ( 'offers' ) }
2026-10-09 22:05:51 +08:00
const fulfillmentOpen = ref ( false ), fulfillmentRow = ref < CapacityInquiry | null >( null ), fulfillmentMode = ref < 'incoming' | 'receipt' | 'bind' | 'view' > ( 'view' )
2026-10-11 15:33:09 +08:00
async function fulfillmentSaved () {
const row = fulfillmentRow . value
fulfillmentOpen . value = false
if ( ! row ) return
const current = ++ followupSequence
await load ()
try {
const updated = await getCapacityInquiry ( row . bookingNo )
if ( current === followupSequence ) await inquiryAction ( 'view' , updated )
} catch ( cause ) { if ( current === followupSequence ) error . value = getApiErrorDetails ( cause , '操作已完成,下一步进度读取失败,请刷新原登记' ) }
}
let followupSequence = 0
2026-10-09 22:05:51 +08:00
function openFulfillment ( key : string , row : CapacityInquiry ) {
2026-10-11 15:33:09 +08:00
if ( key === 'business-order' ) { const target = inquiryOrderTarget ( row , access . can ); if ( target ) void router . push ( target ); return true }
2026-10-09 22:05:51 +08:00
if ( key === 'appointment' && access . can ( 'platform:appointment:list' ) && access . can ( 'platform:overseas-warehouse:last-mile-order:list' )) { void router . push ({ name : 'appointment-calendar' , query : { sourceKey : `BOOKING: ${ row . bookingNo } ` , customerId : row . customerId , destinationKey : row . cargo . destinationKey } }); return true }
2026-10-11 15:33:09 +08:00
if ( key === 'dispatch' && row . dispatchNo && access . can ( 'platform:overseas-warehouse:dispatch:list' )) { void router . push ({ name : 'overseas-dispatch-detail' , params : { dispatchNo : row . dispatchNo }, query : inquirySourceQuery ( row ) }); return true }
if ( key === 'trip' && row . dispatchNo && access . can ( 'platform:fleet:trip:list' )) { void router . push ({ name : 'overseas-master-data-trip-detail' , params : { tripNo : row . tripNo }, query : inquirySourceQuery ( row ) }); return true }
2026-10-09 22:05:51 +08:00
if ( ! [ 'incoming' , 'receipt' , 'bind' , 'fulfillment' ]. includes ( key )) return false
editorOpen . value = false
fulfillmentRow . value = row ; fulfillmentMode . value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind' ; fulfillmentOpen . value = true
return true
}
2026-10-07 16:46:57 +08:00
let sequence = 0 , editorSequence = 0
const fields = computed < QueryField [] >(() => tab . value === 'offers' ? [
{ key : 'origin' , label : '起运仓' , type : 'input' , placeholder : '起运仓名称' , width : 170 }, { key : 'destination' , label : '目的站' , type : 'input' , placeholder : '目的站名称' , width : 170 },
{ key : 'departureFrom' , label : '发车起日' , type : 'date' , placeholder : '发车起日' , width : 170 }, { key : 'departureTo' , label : '发车止日' , type : 'date' , placeholder : '发车止日' , width : 170 },
{ key : 'canConfirm' , label : '接单资格' , type : 'select' , placeholder : '全部接单资格' , width : 150 , options : [{ value : 'true' , label : '可确认承运' }, { value : 'false' , label : '暂不可确认' }] },
2026-10-08 14:11:54 +08:00
...( manage . value ? [{ key : 'scope' , label : '发布范围' , type : 'select' , placeholder : '可登记意向' , width : 180 , options : [{ value : 'AVAILABLE' , label : '可登记意向' }, { value : 'ALL' , label : '全部发布(含历史)' }] } as QueryField , { key : 'status' , label : '发布状态' , type : 'select' , placeholder : '全部发布状态' , width : 140 , options : Object . entries ( offerStatusLabels ). map (([ value , label ]) => ({ value , label })) } as QueryField ] : []),
2026-10-20 21:33:46 +08:00
] : tab . value === 'demands' ? [{ key : 'keyword' , label : '需求关键词' , type : 'input' , placeholder : '原单号 / 起运地 / 目的地' , width : 280 }] : [
2026-10-07 16:46:57 +08:00
{ key : 'keyword' , label : '意向关键词' , type : 'input' , placeholder : '预订号 / 客户 / 车次' , width : 260 },
{ key : 'status' , label : '意向状态' , type : 'select' , placeholder : '全部状态' , width : 150 , options : Object . entries ( inquiryStatusLabels ). map (([ value , label ]) => ({ value , label })) },
2026-10-09 22:05:51 +08:00
...( manage . value ? [{ key : 'scope' , label : '意向范围' , type : 'select' , width : 150 , placeholder : '我的登记' , options : [{ value : 'MINE' , label : '我的登记' }, { value : 'ALL' , label : '全部登记' }, { value : 'TODO' , label : '待我处理' }] } as QueryField ] : []),
2026-10-07 16:46:57 +08:00
])
2026-10-09 22:05:51 +08:00
const columns = computed < DataTableColumn [] >(() => [
{ key : 'actions' , label : '操作' , width : 200 , fixed : 'left' , slotName : 'actions' }, { key : 'offerNo' , label : '发布号 / 车次号' , minWidth : 220 , slotName : 'identity' },
2026-10-07 16:46:57 +08:00
{ key : 'warehouseName' , label : '起运仓' , minWidth : 155 }, { key : 'destinations' , label : '目的站与可接余位' , minWidth : 310 , slotName : 'destinations' , singleLine : false },
2026-10-09 22:05:51 +08:00
{ key : 'plannedDepartureAt' , label : '发车 / 最晚送仓' , minWidth : 195 , slotName : 'times' },
...( manage . value ? [{ key : 'segments' , label : '分段计划占用 / 外接预留' , minWidth : 320 , slotName : 'loads' , singleLine : false }] : []),
2026-10-07 16:46:57 +08:00
{ key : 'status' , label : '接单状态' , minWidth : 170 , slotName : 'status' , singleLine : false }, { key : 'contactName' , label : '调度联系信息' , minWidth : 180 , slotName : 'contact' },
2026-10-09 22:05:51 +08:00
])
const canCreate = computed (() => manage . value || access . can ( 'platform:fleet:capacity-offer:list' ) && access . can ( 'platform:fleet:capacity-offer:inquiry-create' ))
async function create ( row : CapacityOffer ) {
if ( ! canCreate . value || ! row . canCreateInquiry ) return
const current = ++ editorSequence ; error . value = null
try { const value = await getCapacityOffer ( row . offerNo ); if ( current !== editorSequence ) return ; selectedOffer . value = value . offer ; confirmMode . value = false ; editing . value = null ; readonly . value = false ; editorOpen . value = true }
catch ( cause ) { if ( current === editorSequence ) error . value = getApiErrorDetails ( cause , '登记信息读取失败' ) }
}
2026-10-20 21:33:46 +08:00
function switchTab ( value : 'offers' | 'inquiries' | 'demands' ) { if ( tab . value === value ) return ; filters . value = {}; applied . value = {}; page . value = 1 ; total . value = 0 ; tab . value = value }
2026-10-07 16:46:57 +08:00
async function load () {
const current = ++ sequence ; loading . value = true ; error . value = null
try {
const q = applied . value
2026-10-20 21:33:46 +08:00
if ( tab . value === 'demands' ) {
const value = await listLooseboardDemands ({ page : page . value , size : size . value , keyword : String ( q . keyword || '' ) || undefined }); if ( current === sequence ) { demands . value = value . records ; total . value = value . total }
} else if ( tab . value === 'offers' ) {
2026-10-08 14:11:54 +08:00
const query : OfferQuery = { scope : manage . value && q . scope === 'ALL' ? 'ALL' : 'AVAILABLE' , page : page . value , size : size . value , origin : String ( q . origin || '' ) || undefined , destination : String ( q . destination || '' ) || undefined , departureFrom : String ( q . departureFrom || '' ) || undefined , departureTo : String ( q . departureTo || '' ) || undefined , status : manage . value ? String ( q . status || '' ) || undefined : undefined , canConfirm : q . canConfirm === 'true' ? true : q . canConfirm === 'false' ? false : undefined }
2026-10-07 16:46:57 +08:00
const value = await listCapacityOffers ( query ); if ( current === sequence ) { offers . value = value . records ; total . value = value . total }
} else {
2026-10-09 22:05:51 +08:00
const query : InquiryQuery = { page : page . value , size : size . value , keyword : String ( q . keyword || '' ) || undefined , status : String ( q . status || '' ) || undefined , scope : manage . value && q . scope === 'TODO' ? 'TODO' : manage . value && q . scope === 'ALL' ? 'ALL' : 'MINE' }
2026-10-07 16:46:57 +08:00
const value = await listCapacityInquiries ( query ); if ( current === sequence ) { inquiries . value = value . records ; total . value = value . total }
}
2026-10-19 17:56:31 +08:00
} catch ( cause ) { if ( current === sequence ) error . value = getApiErrorDetails ( cause , '散板需求读取失败' ) }
2026-10-07 16:46:57 +08:00
finally { if ( current === sequence ) loading . value = false }
}
function search () { applied . value = { ... filters . value }; if ( page . value !== 1 ) page . value = 1 ; else void load () }
function accept ( value : InquiryResult ) {
2026-10-20 21:33:46 +08:00
if ( selectedDemand . value && value . inquiry . businessOrderNo === selectedDemand . value . orderNo ) { selectedDemand . value = null ; switchTab ( 'inquiries' ) }
2026-10-07 16:46:57 +08:00
const index = inquiries . value . findIndex ( row => row . bookingNo === value . inquiry . bookingNo )
if ( index >= 0 ) inquiries . value [ index ] = value . inquiry
for ( const row of inquiries . value ) if ( row . offerNo === value . offer . offerNo ) { row . offerRecordVersion = value . offer . recordVersion ; row . tripRecordVersion = value . offer . tripRecordVersion }
selectedOffer . value = value . offer
}
const commands = useCapacityInquiryCommands ( 'capacity-pool-command' , accept )
async function inquiryAction ( key : string , row : CapacityInquiry ) {
2026-10-11 15:33:09 +08:00
followupSequence ++
2026-10-09 22:05:51 +08:00
if ( openFulfillment ( key , row )) return
if ( key === 'cancel' ) { await commands . run ( row , key ); return }
if ( key === 'confirm' && ! manage . value ) return
confirmMode . value = key === 'confirm'
2026-10-07 16:46:57 +08:00
const current = ++ editorSequence ; error . value = null
2026-10-09 22:05:51 +08:00
if ( key === 'edit' || key === 'confirm' ) {
2026-10-07 16:46:57 +08:00
try { const detail = await getCapacityOffer ( row . offerNo ); if ( current !== editorSequence ) return ; selectedOffer . value = detail . offer }
catch ( cause ) { if ( current === editorSequence ) error . value = getApiErrorDetails ( cause , '意向编辑信息读取失败' ); return }
} else selectedOffer . value = null
2026-10-09 22:05:51 +08:00
editing . value = row ; readonly . value = key !== 'edit' && key !== 'confirm' ; editorOpen . value = true
2026-10-07 16:46:57 +08:00
}
async function recheckEditor () {
if ( ! editing . value ) return
const current = ++ editorSequence
try {
const detail = await getCapacityOffer ( editing . value . offerNo )
const checked = detail . inquiries . records . find ( row => row . bookingNo === editing . value ? . bookingNo ) || await getCapacityInquiry ( editing . value . bookingNo )
if ( current === editorSequence ) { selectedOffer . value = detail . offer ; editing . value = checked }
} catch ( cause ) { if ( current === editorSequence ) error . value = getApiErrorDetails ( cause , '最新意向核对失败' ) }
}
2026-10-11 15:33:09 +08:00
watch ([ tab , page , size ], load ); onMounted ( load ); onBeforeUnmount (() => { followupSequence ++ ; sequence ++ ; editorSequence ++ })
2026-10-07 16:46:57 +08:00
</ script >
2026-10-19 17:56:31 +08:00
< template >< AdminPage class = "admin-density" >< ListPageLayout aria -label =" 散板需求 " >
2026-10-20 21:33:46 +08:00
< div class = "density-status-tabs" role = "tablist" aria -label =" 需求来源 " >< button v-if = "canCreate" role="tab" :aria-selected="tab === 'demands'" :class="{ 'is-active': tab === 'demands' }" @click="switchTab('demands')">待匹配散板</button><button role="tab" :aria-selected="tab === 'offers'" :class="{ 'is-active': tab === 'offers' }" @click="switchTab('offers')">可拼车次</button><button role="tab" :aria-selected="tab === 'inquiries'" :class="{ 'is-active': tab === 'inquiries' }" @click="switchTab('inquiries')" > 我的登记 </ button ></ div >
< p v-if = "selectedDemand && tab === 'offers'">正在匹配 {{ selectedDemand.orderNo }}: {{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。<el-button link @click="selectedDemand = null" > 取消选择 </ el -button ></ p >
< DataTable v-if = "tab === 'demands'" appearance="integrated" :stripe="false" preference-key="fleet-looseboard-demands" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="demands" :columns="demandColumns" :loading="loading" :total="total" row-key="orderNo" empty-text="暂无待匹配散板需求" >
< template # toolbar -start >< h1 > 待匹配散板 </ h1 ></ template >
< template # filters >< QueryToolbar v-model = "filters" :fields="fields" ariaLabel="待匹配散板查询" :loading="loading" @search="search" /></ template >
< template # notices >< LoadErrorState v-if = "error" title="需求读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState ></ template >
< template # actions = "{ row }" >< div class = "row-actions density-actions admin-text-actions" >< el-button class = "density-action" v-if = "canCreate" @click="matchDemand(row as LooseboardDemand)" > 匹配车次 </ el -button ></ div ></ template >
</ DataTable >
2026-10-07 16:46:57 +08:00
< DataTable v-if = "tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'" >
2026-10-19 17:56:31 +08:00
< template # toolbar -start >< h1 > 散板需求 </ h1 ></ template >< template # toolbar -end >< el-button v-if = "manage" @click="switchTab('inquiries'); filters = { scope: 'TODO' }; applied = { scope: 'TODO' }">待我处理</el-button><el-button v-if="manage && access.can('platform:fleet:trip:list')" @click="router.push({ name: 'overseas-master-data-trips' })" > 选择车次发布余位 </ el -button ></ template >
2026-10-08 14:11:54 +08:00
< template # filters >
< div class = "integrated-query-group" >
2026-10-09 22:05:51 +08:00
< QueryToolbar v-model = "filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
2026-10-08 14:11:54 +08:00
</ div >
</ template >
2026-10-07 16:46:57 +08:00
< template # notices >< LoadErrorState v-if = "error" title="运力列表读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState ></ template >
2026-10-09 22:05:51 +08:00
< template # actions = "{ row }" >< div class = "row-actions density-actions admin-text-actions" >< el-button class = "density-action" v-if = "canCreate" :disabled="!row.canCreateInquiry" @click="create(row as CapacityOffer)">登记客户货物</el-button><el-button class="density-action" @click="router.push({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: row.offerNo } })" > 详情 </ el -button ></ div ></ template >
2026-10-07 16:46:57 +08:00
< template # identity = "{ row }" >< div >{{ row . offerNo }}</ div >< div >{{ row . tripNo }}</ div ></ template >
< template # destinations = "{ row }" >< div v-for = "stop in (row as CapacityOffer).destinations" :key="stop.destinationKey" >{{ stop.name }} · {{ row.capacityKnown ? loadText ( stop.remaining ) : ' 待调度核对 ' }}</ div ></ template >
2026-10-09 22:05:51 +08:00
< template # times = "{ row }" >< div > 发车 {{ offerTime ( row . plannedDepartureAt ) }}</ div >< div > 最晚送仓 {{ offerTime ( row . cutoffAt ) }}</ div ></ template >
2026-10-07 18:21:03 +08:00
< template # loads = "{ row }" >< span v-if = "!row.segments.length">{{ row.capacityKnown ? '—' : '待调度核对' }}</span><div v-for="segment in (row as CapacityOffer).segments" :key="segment.fromIndex" >{{ segment.fromName }} → {{ segment.toName }}< br > 计划 {{ loadText ( segment . planned ) }}< br > 预留 {{ loadText ( segment . reserved ) }}</ div ></ template >
2026-10-09 22:05:51 +08:00
< template # status = "{ row }" >{{ offerStatusLabels [( row as CapacityOffer ). status ] }} · {{ row . canConfirm ? '可登记,确认后保位' : row . canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}< div v-if = "row.blockReason" >{{ row.blockReason }}</ div ></ template >
2026-10-07 16:46:57 +08:00
< template # contact = "{ row }" >< div >{{ row . contactName || '—' }}</ div >< div >{{ row . contactPhone || '—' }}</ div ></ template >
</ DataTable >
2026-10-20 21:33:46 +08:00
< CapacityInquiryTable v-else-if = "tab === 'inquiries'" integrated v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction" >
2026-10-09 22:05:51 +08:00
< template # toolbar -start >< h1 >{{ applied . scope === 'TODO' ? '待我处理' : applied . scope === 'ALL' ? '全部登记' : '我的登记' }}</ h1 ></ template >
2026-10-08 14:11:54 +08:00
< template # filters >
< div class = "integrated-query-group" >
< QueryToolbar v-model = "filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" />
</ div >
</ template >
2026-10-07 16:46:57 +08:00
< template # notices >< LoadErrorState v-if = "error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry" > 重试原操作 </ el -button ></ template >
</ CapacityInquiryTable >
2026-10-11 15:33:09 +08:00
< CapacityInquiryFulfillment v-model = "fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
2026-10-20 21:33:46 +08:00
< CapacityInquiryDialog v-model = "editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
2026-10-07 16:46:57 +08:00
</ ListPageLayout ></ AdminPage ></ template >