103 lines
10 KiB
Vue
103 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { inquiryReturnTarget, vehicleStatusLabels } from '@/modules/overseas-master-data'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import { getDispatch } from '../api/dispatches'
|
||
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||
import { stageLabel } from '../dispatchContracts'
|
||
import { canDispatchAction, dispatchActionLabels, displayDispatchValue as text } from '../dispatchPresentation'
|
||
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
|
||
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
|
||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||
import { formatOverseasDateTime as time } from '../lastMilePresentation'
|
||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||
|
||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
|
||
const detail = ref<DispatchDetail | null>(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref<ApiErrorDetails | null>(null)
|
||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||
const operation = ref<'ROUTE' | undefined>()
|
||
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
|
||
const detailActions: DispatchAction[] = ['EDIT', 'ADD_CARGO', 'REMOVE_CARGO', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'ARRANGE_DELIVERY', 'DEPART', 'CANCEL']
|
||
const visibleActions = computed(() => detailActions.filter(value => canDispatchAction(detail.value?.actions, value, access.can)))
|
||
const vehicleStatus = computed(() => {
|
||
const status = detail.value?.vehicleInfo.vehicle?.vehicleStatus
|
||
return status && Object.hasOwn(vehicleStatusLabels, status) ? vehicleStatusLabels[status as keyof typeof vehicleStatusLabels] : '—'
|
||
})
|
||
const vehicleFields = computed(() => [
|
||
['车牌号', text(detail.value?.vehicleInfo.vehicle?.plate)], ['司机', text(detail.value?.vehicleInfo.driver?.name)],
|
||
['联系电话', text(detail.value?.vehicleInfo.driver?.phone)], ['拖车公司名称', text(detail.value?.vehicleInfo.vehicle?.carrierName)], ['车辆状态', vehicleStatus.value],
|
||
])
|
||
const dispatchFields = computed(() => [
|
||
['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)],
|
||
['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)],
|
||
['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'],
|
||
])
|
||
const documentFields = computed(() => [
|
||
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
|
||
['更新时间', time(detail.value?.documents.updatedAt)], ['备注', text(detail.value?.documents.remark)],
|
||
])
|
||
const relatedOrders = computed(() => detail.value?.orderDetails || [])
|
||
const orderColumns: DataTableColumn[] = [
|
||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
|
||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
|
||
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
|
||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
|
||
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
|
||
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
|
||
{ key: 'fbaWarehouse', prop: 'destinationFbaWarehouse', label: '目的FBA仓', minWidth: 150 },
|
||
{ key: 'volume', prop: 'volumeCbm', label: '体积(m³)', minWidth: 130, align: 'right' },
|
||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 170 },
|
||
]
|
||
let generation = 0
|
||
async function load() {
|
||
const current = ++generation, no = dispatchNo.value
|
||
detail.value = null; loadError.value = null
|
||
if (!no) { loadError.value = { message: '详情缺少排车单号,请从排车列表重新进入' }; return }
|
||
loading.value = true
|
||
try { const result = await getDispatch(no); if (current === generation) { if (result.dispatchNo !== no) throw new Error('返回排车单与当前业务号不一致'); detail.value = result } }
|
||
catch (error) { if (current === generation) loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') }
|
||
finally { if (current === generation) loading.value = false }
|
||
}
|
||
function back() { void router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
|
||
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (!canDispatchAction(detail.value?.actions, value, access.can)) return; rejectionError.value = null; operation.value = requestedOperation; action.value = value; actionOpen.value = true }
|
||
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
|
||
async function handleSaved() { rejectionError.value = null; await load() }
|
||
watch(dispatchNo, load, { immediate: true })
|
||
onBeforeUnmount(() => { generation++ })
|
||
</script>
|
||
|
||
<template>
|
||
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
|
||
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
|
||
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||
<section v-if="detail" class="detail-section" aria-label="排车操作"><div class="density-actions"><el-button v-for="value in visibleActions" :key="value" class="density-action" @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button><el-button v-if="canDispatchAction(detail?.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button></div></section>
|
||
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
|
||
<el-tab-pane name="vehicle"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span><span>车辆信息</span></template><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.vehicle?.evidenceFileIds || []" :dispatch-no="dispatchNo" kind="VEHICLE" readonly /><DispatchEvidenceFiles :model-value="detail?.vehicleInfo.driver?.evidence?.fileIds || []" :dispatch-no="dispatchNo" kind="DRIVER" readonly /></el-tab-pane>
|
||
<el-tab-pane name="dispatch"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>排车信息</span></template><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></el-tab-pane>
|
||
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
|
||
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
|
||
<div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div>
|
||
</el-tab-pane>
|
||
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></el-tab-pane>
|
||
</el-tabs>
|
||
</div>
|
||
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
|
||
</OverseasDetailShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.detail-section { min-width: 0; padding: var(--admin-space-5); }
|
||
.dispatch-detail { min-width: 0; min-height: 0; }
|
||
.linked-documents { grid-column: 1 / -1; }
|
||
.linked-documents dd { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); overflow-wrap: anywhere; }
|
||
@media (max-width: 960px) { .detail-fields { grid-template-columns: 1fr; } .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
|
||
</style>
|