Files
admin-web/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
T

123 lines
13 KiB
Vue
Raw Normal View History

2026-08-21 18:16:42 +08:00
<script setup lang="ts">
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-09-14 20:03:27 +08:00
import '@/styles/adminDensity.css'
2026-10-19 17:56:31 +08:00
import { computed, onBeforeUnmount, ref, watch } from 'vue'
2026-08-21 18:16:42 +08:00
import { useRoute, useRouter } from 'vue-router'
import { inquiryReturnTarget, vehicleStatusLabels } from '@/modules/overseas-master-data'
2026-10-20 16:45:09 +08:00
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-21 18:16:42 +08:00
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'
2026-10-19 17:56:31 +08:00
import { getDispatch } from '../api/dispatches'
import type { DispatchAction, DispatchDetail } from '../dispatchContracts'
import { stageLabel } from '../dispatchContracts'
2026-10-21 15:49:35 +08:00
import { dispatchExceptionLabels, dispatchReturnDirectionLabels, canDispatchAction, dispatchActionLabels, dispatchRejectionReason, dispatchReviewAttachmentFields, dispatchReviewEvidence, displayDispatchValue as text } from '../dispatchPresentation'
2026-10-20 21:33:46 +08:00
import TransportExecutionPanel from '../components/TransportExecutionPanel.vue'
2026-10-19 17:56:31 +08:00
import DispatchWorkflowDialog from '../components/DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from '../components/DispatchEvidenceFiles.vue'
2026-08-21 18:16:42 +08:00
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
2026-10-19 17:56:31 +08:00
import { formatOverseasDateTime as time } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
2026-08-21 18:16:42 +08:00
2026-10-19 17:56:31 +08:00
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
2026-08-21 18:16:42 +08:00
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
2026-10-19 17:56:31 +08:00
const detail = ref<DispatchDetail | null>(null), activeTab = ref('vehicle'), loading = ref(false), loadError = ref<ApiErrorDetails | null>(null)
2026-10-21 15:10:54 +08:00
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
2026-10-20 14:25:01 +08:00
const rejectionError = ref<ApiErrorDetails | null>(null)
2026-10-19 17:56:31 +08:00
const operation = ref<'ROUTE' | undefined>()
const action = ref<DispatchAction>('EDIT'), actionOpen = ref(false)
2026-10-21 14:30:33 +08:00
const detailActions: DispatchAction[] = ['EDIT', 'SUBMIT_REVIEW', 'DISPATCH_REVIEW', 'LOADING_REVIEW', 'RETURN_TO_REVIEW', 'LOADING_COMPLETE', 'CANCEL']
2026-10-21 15:20:24 +08:00
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] : '—'
})
2026-10-19 17:56:31 +08:00
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],
2026-08-21 18:16:42 +08:00
])
2026-10-19 17:56:31 +08:00
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(';') || '—'],
2026-08-21 18:16:42 +08:00
])
2026-10-19 17:56:31 +08:00
const documentFields = computed(() => [
['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)],
['更新时间', time(detail.value?.documents.updatedAt)], ['备注', text(detail.value?.documents.remark)],
2026-08-21 18:16:42 +08:00
])
2026-10-19 17:56:31 +08:00
const relatedOrders = computed(() => detail.value?.orderDetails || [])
2026-08-21 18:16:42 +08:00
const orderColumns: DataTableColumn[] = [
2026-10-19 17:56:31 +08:00
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
2026-08-21 18:16:42 +08:00
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
2026-10-19 17:56:31 +08:00
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
2026-08-21 18:16:42 +08:00
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
2026-10-19 17:56:31 +08:00
{ key: 'fbaWarehouse', prop: 'destinationFbaWarehouse', label: '目的FBA仓', minWidth: 150 },
{ key: 'volume', prop: 'volumeCbm', label: '体积(m³)', minWidth: 130, align: 'right' },
2026-08-21 18:16:42 +08:00
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 170 },
]
2026-10-19 17:56:31 +08:00
let generation = 0
2026-08-21 18:16:42 +08:00
async function load() {
2026-10-19 17:56:31 +08:00
const current = ++generation, no = dispatchNo.value
detail.value = null; loadError.value = null
if (!no) { loadError.value = { message: '详情缺少排车单号,请从排车列表重新进入' }; return }
2026-08-21 18:16:42 +08:00
loading.value = true
2026-10-19 17:56:31 +08:00
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 }
2026-08-21 18:16:42 +08:00
}
2026-10-20 16:45:09 +08:00
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' }) }
2026-10-21 13:22:42 +08:00
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 }
2026-10-20 14:25:01 +08:00
async function handleRejection(details: ApiErrorDetails) { rejectionError.value = details; await load() }
async function handleSaved() { rejectionError.value = null; await load() }
2026-10-19 17:56:31 +08:00
watch(dispatchNo, load, { immediate: true })
onBeforeUnmount(() => { generation++ })
2026-08-21 18:16:42 +08:00
</script>
<template>
2026-10-20 23:34:11 +08:00
<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">
2026-10-20 14:25:01 +08:00
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
2026-08-21 18:16:42 +08:00
<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">
2026-10-21 15:20:24 +08:00
<TransportExecutionPanel v-if="detail?.taskType && detail.taskType !== 'DELIVERY'" :dispatch-no="dispatchNo" :pickup-approved="detail.reviewState === 'APPROVED'" @saved="load" />
2026-10-21 14:30:33 +08:00
<el-alert v-if="dispatchRejectionReason(detail?.reviews)" title="已退回客服处理(车辆信息),请核对原因后重新提交调度审核。" :description="dispatchRejectionReason(detail?.reviews)" type="warning" :closable="false" show-icon />
2026-10-21 16:40:10 +08:00
<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>
2026-10-21 15:49:35 +08:00
<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>
2026-10-19 17:56:31 +08:00
<el-tab-pane name="documents"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>单据信息</span></template>
2026-10-21 16:40:10 +08:00
<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>
2026-10-19 17:56:31 +08:00
<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>
2026-10-20 23:34:11 +08:00
<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>
2026-10-19 17:56:31 +08:00
</el-tabs>
2026-10-21 16:40:10 +08:00
<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>
2026-08-21 18:16:42 +08:00
</div>
2026-10-20 14:25:01 +08:00
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
2026-08-21 18:16:42 +08:00
</OverseasDetailShell>
</template>
<style scoped>
2026-09-14 20:03:27 +08:00
.detail-section { min-width: 0; padding: var(--admin-space-5); }
.dispatch-detail { min-width: 0; min-height: 0; }
2026-10-21 16:40:10 +08:00
.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); }
2026-10-19 17:56:31 +08:00
.linked-documents { grid-column: 1 / -1; }
.linked-documents dd { display: flex; flex-wrap: wrap; gap: var(--admin-space-4); overflow-wrap: anywhere; }
2026-10-21 16:40:10 +08:00
@media (max-width: 960px) { .dispatch-detail .detail-fields { grid-template-columns: 1fr; } .dispatch-detail .detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); } }
2026-08-21 18:16:42 +08:00
</style>