123 lines
13 KiB
Vue
123 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminWorkbench.css'
|
||
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 { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||
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 { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
|
||
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
|
||
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 reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
|
||
const rejectionError = ref<ApiErrorDetails | null>(null)
|
||
const operation = ref<'ROUTE' | undefined>()
|
||
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
|
||
const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
|
||
const visibleActions = computed(() => detailActions.filter(value => !(detail.value?.taskType === 'PICKUP' && value === 'EDIT') && 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 }
|
||
}
|
||
const appointmentReturn = computed(() => appointmentReturnTarget(route.query))
|
||
const backLabel = computed(() => appointmentReturn.value ? '返回预约日历' : inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表')
|
||
function back() { void router.push(appointmentReturn.value || inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
|
||
function openAction(value: DispatchAction, requestedOperation?: 'ROUTE') { if (value === 'ADD_CARGO' || value === 'REMOVE_CARGO') return; 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 workbench class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="appointmentReturn || inquiryReturnTarget(route.query) ? backLabel : undefined" :back-label="backLabel" 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">
|
||
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" @saved="load" />
|
||
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
|
||
<el-tabs v-if="!detail?.taskType || detail.taskType === 'DELIVERY'" v-model="activeTab" class="density-element-tabs density-contained-tabs density-contained-tabs--flush">
|
||
<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 vehicle-fields"><div v-for="field in vehicleFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" 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><section v-if="detail && detail.dispatchInfo.exceptions.length" class="detail-section" aria-label="派送异常记录">
|
||
<h3>派送异常记录</h3>
|
||
<article v-for="(record, index) in detail.dispatchInfo.exceptions" :key="index">
|
||
<dl class="density-fields"><div class="density-field"><dt>异常类型</dt><dd>{{ dispatchExceptionLabels[record.exceptionType] }}</dd></div><div class="density-field"><dt>登记时间</dt><dd>{{ time(record.occurredAt) }}</dd></div><div class="density-field"><dt>退货去向</dt><dd>{{ record.returnDirection ? dispatchReturnDirectionLabels[record.returnDirection] : '—' }}</dd></div><div class="density-field"><dt>异常说明</dt><dd>{{ record.description || '—' }}</dd></div></dl>
|
||
<DispatchEvidenceFiles v-if="record.imageFileIds.length" :model-value="record.imageFileIds" :dispatch-no="dispatchNo" kind="EXCEPTION" label="异常图片" readonly />
|
||
</article>
|
||
</section></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 }, query: { customerId: delivery.customerId } })">{{ 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><div class="workbench-table"><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></div></el-tab-pane>
|
||
</el-tabs>
|
||
<footer v-if="detail && visibleActions.length" class="dispatch-actions density-actions" aria-label="排车操作">
|
||
<el-button v-for="value in visibleActions" :key="value" :type="value === 'CANCEL' ? 'danger' : undefined" plain @click="openAction(value)">{{ dispatchActionLabels[value] }}</el-button>
|
||
<el-button v-if="(!detail.taskType || detail.taskType === 'DELIVERY') && canDispatchAction(detail.actions, 'EDIT', access.can)" @click="openAction('EDIT', 'ROUTE')">调整卸货顺序</el-button>
|
||
</footer>
|
||
</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; }
|
||
.dispatch-actions { justify-content: flex-end; flex-wrap: wrap; padding: var(--admin-space-5); }
|
||
.dispatch-detail .vehicle-fields { grid-template-columns: minmax(0, 1.3fr) minmax(0, 1.6fr) minmax(0, 1.2fr) minmax(0, 1.5fr) minmax(0, 0.8fr); }
|
||
.dispatch-detail .vehicle-fields > div { grid-template-columns: max-content minmax(0, 1fr); }
|
||
.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) { .dispatch-detail .detail-fields { grid-template-columns: 1fr; } .dispatch-detail .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
|
||
</style>
|