184 lines
20 KiB
Vue
184 lines
20 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
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'
|
||
import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment.vue'
|
||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||
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'
|
||
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
|
||
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||
const router = useRouter(), access = useAccessPreviewStore()
|
||
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||
// 当前只展示余位需求池;登记、原单匹配及履约模板直接隐藏,原实现保留。
|
||
const tab = ref<'offers' | 'inquiries' | 'demands'>('offers')
|
||
const filters = ref<QueryModel>({}), applied = ref<QueryModel>({ ...filters.value })
|
||
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)
|
||
const confirmMode = ref(false)
|
||
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') }
|
||
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||
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
|
||
function openFulfillment(key: string, row: CapacityInquiry) {
|
||
if (key === 'business-order') { const target = inquiryOrderTarget(row, access.can); if (target) void router.push(target); return true }
|
||
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 }
|
||
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 }
|
||
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
|
||
}
|
||
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: '待核对或不可承运' }] },
|
||
...(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] : []),
|
||
] : tab.value === 'demands' ? [{ key: 'keyword', label: '需求关键词', type: 'input', placeholder: '原单号 / 起运地 / 目的地', width: 280 }] : [
|
||
{ 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 })) },
|
||
...(manage.value ? [{ key: 'scope', label: '意向范围', type: 'select', width: 150, placeholder: '我的登记', options: [{ value: 'MINE', label: '我的登记' }, { value: 'ALL', label: '全部登记' }, { value: 'TODO', label: '待我处理' }] } as QueryField] : []),
|
||
])
|
||
const columns = computed<DataTableColumn[]>(() => [
|
||
{ key: 'actions', label: '操作', width: 200, fixed: 'left', slotName: 'actions' }, { key: 'offerNo', label: '发布号 / 车次号', minWidth: 220, slotName: 'identity' },
|
||
{ key: 'warehouseName', label: '起运仓', minWidth: 155 }, { key: 'destinations', label: '目的站与可接余位', minWidth: 310, slotName: 'destinations', singleLine: false },
|
||
{ key: 'plannedDepartureAt', label: '发车 / 最晚送仓', minWidth: 195, slotName: 'times' },
|
||
...(manage.value ? [{ key: 'segments', label: '分段计划占用 / 外接预留', minWidth: 320, slotName: 'loads', singleLine: false }] : []),
|
||
{ key: 'status', label: '发布状态', minWidth: 170, slotName: 'status', singleLine: false }, { key: 'contactName', label: '调度联系信息', minWidth: 180, slotName: 'contact' },
|
||
])
|
||
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, '登记信息读取失败') }
|
||
}
|
||
function switchTab(value: 'offers' | 'inquiries' | 'demands') { if (value !== 'offers' || tab.value === value) return; filters.value = {}; applied.value = {}; page.value = 1; total.value = 0; tab.value = value }
|
||
async function load() {
|
||
const current = ++sequence; loading.value = true; error.value = null
|
||
try {
|
||
const q = applied.value
|
||
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') {
|
||
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 }
|
||
const value = await listCapacityOffers(query); if (current === sequence) { offers.value = value.records; total.value = value.total }
|
||
} else {
|
||
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' }
|
||
const value = await listCapacityInquiries(query); if (current === sequence) { inquiries.value = value.records; total.value = value.total }
|
||
}
|
||
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '散板需求读取失败') }
|
||
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) {
|
||
if (selectedDemand.value && value.inquiry.businessOrderNo === selectedDemand.value.orderNo) { selectedDemand.value = null; switchTab('inquiries') }
|
||
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) {
|
||
followupSequence++
|
||
if (openFulfillment(key, row)) return
|
||
if (key === 'cancel') { await commands.run(row, key); return }
|
||
if (key === 'confirm' && !manage.value) return
|
||
confirmMode.value = key === 'confirm'
|
||
const current = ++editorSequence; error.value = null
|
||
if (key === 'edit' || key === 'confirm') {
|
||
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
|
||
editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true
|
||
}
|
||
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, '最新意向核对失败') }
|
||
}
|
||
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
|
||
</script>
|
||
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="散板需求">
|
||
<p v-if="selectedDemand && tab === 'offers'">正在匹配 {{ selectedDemand.orderNo }}:{{ selectedDemand.origin }} → {{ selectedDemand.destination }}。请选择合适车次,保存登记后尚不保位。<el-button link @click="selectedDemand = null">取消选择</el-button></p>
|
||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||
<div v-if="tab === 'demands'" class="workbench-table is-page-surface"><DataTable appearance="integrated" 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><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="true" class="is-active" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div></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></div>
|
||
-->
|
||
<div v-if="tab === 'offers'" class="workbench-table is-page-surface"><DataTable appearance="integrated" 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) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||
<template #toolbar-start><h1>散板需求</h1><!-- 当前暂不开放登记与拼车;原代码保留。
|
||
<div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="false" @click="switchTab('inquiries')">我的登记</button></div>
|
||
--></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>
|
||
<template #filters>
|
||
<div class="integrated-query-group">
|
||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="可拼车次查询" :loading="loading" @search="search" />
|
||
</div>
|
||
</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" :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>
|
||
<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>
|
||
<template #times="{ row }"><div>发车 {{ offerTime(row.plannedDepartureAt) }}</div><div>最晚送仓 {{ offerTime(row.cutoffAt) }}</div></template>
|
||
<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>
|
||
<template #status="{ row }">{{ offerStatusLabels[(row as CapacityOffer).status] }}<!-- 当前暂不开放登记与拼车;原代码保留。
|
||
<template> · {{ row.canConfirm ? '可登记,确认后保位' : row.canCreateInquiry ? '可先登记,待调度核对,尚不保位' : '暂不可登记' }}</template>
|
||
--><div v-if="row.blockReason">{{ row.blockReason }}</div></template>
|
||
<template #contact="{ row }"><div>{{ row.contactName || '—' }}</div><div>{{ row.contactPhone || '—' }}</div></template>
|
||
</DataTable></div>
|
||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||
<CapacityInquiryTable v-if="tab === 'inquiries'" integrated class="is-page-surface" v-model:page="page" v-model:size="size" :rows="inquiries" :total="total" :loading="loading" :failed="!!error" :busy="commands.busy.value" @action="inquiryAction">
|
||
<template #toolbar-start><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="需求来源"><button v-if="canCreate" role="tab" aria-selected="false" @click="switchTab('demands')">待匹配散板</button><button role="tab" aria-selected="false" @click="switchTab('offers')">可拼车次</button><button role="tab" aria-selected="true" class="is-active" @click="switchTab('inquiries')">我的登记</button></div><span v-if="applied.scope === 'TODO' || applied.scope === 'ALL'" class="workbench-badge">{{ applied.scope === 'TODO' ? '待我处理' : '全部登记' }}</span></template>
|
||
<template #filters>
|
||
<div class="integrated-query-group">
|
||
<QueryToolbar v-model="filters" :fields="fields" ariaLabel="拼车意向查询" :loading="loading" @search="search" />
|
||
</div>
|
||
</template>
|
||
<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>
|
||
-->
|
||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||
-->
|
||
<!-- 当前暂不开放登记与拼车;原代码保留。
|
||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :source-demand="editing ? null : selectedDemand" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
||
-->
|
||
</ListPageLayout></AdminPage></template>
|